Skip to content

Workstation

The workstation view is the core view of the web app. Using this view users remote control their workstation. Initially, until the workstation is ready, a loading screen is displayed to the user.

Architecture

The workstations users remote control are technically docker containers. This is the main difference compared to Deskterm's predecessor, Instant Workstation. On Instant Workstation, the workstations are virtual machines. Another key difference is that the Deskterm workstations run on the Fly.io cloud. On Instant Workstations, the workstations run my home in Joensuu, Finland.

The main reason for switching to docker containers is to allow users to run their workstations in the Fly.io cloud close to where they are. For example, if the user is located in Auckland, New Zealand, they may choose to run their workstation at the nearest Fly.io location, in this case Sydney, Australia. With Instant Workstation, the user's machine always runs in Joensuu, Finland. The network latency between Auckland and Sydney is much lower than between Auckland and Joensuu.

Technically, I could have also chosen to run virtual machines in the cloud. However, this is actually much more costly. To run the user's virtual machines in a performant way, they need to be run at near native speed using for example Kernel-based Virtual Machine (KVM). If the machine that Instant Workstation is running on is already virtualized, then to run the user's virtual machine would require nested virtualiztion. Whilst this is technically possible, to do this in a performant way would require Nested KVM. Most cloud providers don't offer nested KVM in their offerings. Therefore if you ran a nested virtual machine the performance would be terrible. So, in order to actually run virtual machines worldwide in a performant way, I would have needed to rent dedicated servers (non-virtualized) at much higher costs. I would also lose the cloud benefits if I ran physical hardware.

Docker containers can be nested much more easily. Therefore containerization was chosen over virtualization for Deskterm.

Infrastructure

The workstations (containers) run on Fly.io virtual machines. Fly.io machines are created/started/stopped by Deskterm as needed. Essentially Deskterm is an orchestrator of user workstations on the Fly.io cloud.

Launch Sequence

How a workstation is started is defined by a launch sequence. The launch sequences can be found in launch_sequence.ex. There are different launch sequences for all of the below scenarios:

  • Free user starts a workstation
  • New user starts a workstation
  • Old user starts a new workstation
  • Old user starts an old workstation

A free user refers to an either authenticated or unauthenticated user that doesn't have a Deskterm subscription. A new user refers to a user that has never started a workstation on Deskterm before. An old user refers to a user that has previously started one ore more workstations on Deskterm.

There can be many steps in a launch sequence, for example for free users the launch sequence is as follows:

  • Initialize Session
  • Create Fly.io App
  • Allocate IPv4 Address
  • Allocate IPv6 Address
  • Create Volume
  • Create Machine
  • Wait for Machine to come Online
  • Wait for Docker to come Online
  • Configure Firewall
  • Pull Workstation's Docker Image
  • Start Workstation Container
  • Configure Authentication Proxy
  • Start Authentication Proxy
  • Update Session
  • Start Session Countdown

Loading Screen

Starting a workstation for the first time can take a lot of time, approximately 1 to 2 minutes. Most of this time is spent on pulling the docker image. Starting an existing workstation, stored in persistent storage, takes approximately 10 to 20 seconds. Note that for free users the workstation is not stored in persistent storage, so for them the waiting time is always around 1 to 2 minutes.

With such a long waiting time it is important to give users frequent and accurate feedback about what is currently happening and how far they are in the launch process.

UI Log

The UI log shows users what is currently happening. In the case of API calls, it shows the endpoint being called. In the case of commands executed on Fly.io machines via SSH, both the command and the output from the command are shown on the UI log. The command output is streamed from the Fly.io machine to the user's web browser (via the Deskterm backend).

Whilst streaming command output from the orchestration commands may sound risky at first, it should be noted that no Deskterm secrets are located on the Fly.io machines. The only secret present on the Fly.io machines is the user's authentication token, however since the user's web browser also receives this token it is nothing to be kept secret from the user.

No secrets are also shown in the UI log when the endpoints calls are displayed. Only the HTTP method and URL are sent to the user's UI log, not the headers. Only the headers contain a secret (Fly.io API token). The URL should not contain any secrets.

Censorship

Despite no Deskterm secrets being leaked through the UI log to the user under normal circumstances, out of an abundance of caution, a censorship module was implemented. Before any data is sent to the user's UI log, it first passes through the censorship module. This modules replaces any secrets it finds in the output with stars (*). For even more caution, the censorship module looks for snippets/fragmnts of the secrets in the output. So even if only half of the FLY_API_TOKEN was present in the data, it would still get censored by the censorship module.

The censorship module also censors the user's own authentication token. Whilst this is not secret to the user, it is still better to censor it since it would be shown in plaintext on the user's screen. For example, this mitigates the risk where a malicious person looks over the user's shoulder and sees the user's authentication token.

Security

Authentication

To prevent unauthorized access to user's virtual machines, they are not directly accessible from the internet. Instead, what is publicly accessible, is an authentication proxy running on the same Fly.io machine as the user's workstation. The proxy only allows connections to the workstation for a whitelisted IP address. The user's IP address is whitelisted through an authenticate endpoint on the proxy which requires a valid token (issued for that particular user by the Deskterm backend).

The security that only the user that started the workstation can access it comes from two separate aspects:

  • The Fly.io workstation URL includes 16 random characters (determined by Deskterm) that are hard to guess
    • Each workstation gets a unique URL
  • Workstation connections are only allowed from the user's own IP address

If the user's IP address changes, then on next machine start their new IP address would be whitelisted. The whitelisted IP address are only temporary and are forgotten when the user's session ends.

Isolation

To minimize security risks, it was chosen to create a separate Fly.io app for each user on Deskterm. In this sense the user isolation is essentially outsourced to Fly.io. Machines on Fly.io that are located in separate apps are isolated from each other and cannot see each other, even if they happen to run on the same physical host.

On Instant Workstation, the virtual machine isolation was handled in-house. This is much riskier as the setup is more complex and more error-prone. The Firewall rules needed for Instant Workstation are substantially more complex than those needed for Deskterm.

Abuse Prevention

To prevent abuse and runaway costs, sessions for free users are limited to 5 minutes. Furthermore internet access is blocked for free users. Internet access is only available to paid users. Without these precautions, there is a higher risk some malicious users may use the service for undesirable activity, for example:

  • Crypto Mining
  • Spam
  • DDoS Attacks

Audit

Everytime a workstation is started, regardless of whether by free or paid users, the following is logged to the Deskterm database:

  • User's IP address
  • Workstation start time
  • Workstation end time
  • Deskterm User (if logged in)

This audit trail may help narrow down from which user the nefarious activity originated in case of complaints.

Bot Protection

To prevent bots from starting workstations, Cloudflare Turnstile is used to first establish that the request comes from a human. On the client-side, Cloudflare checks if the user seems human. If there are any doubts Cloudflare may present the user with a challenge to tick a box. If Cloudflare thinks the user is human, it issues a token to the client. When the client then starts a workstation, it includes the Cloudflare token with the request. The Deskterm backend then verifies the token from Cloudflare before proceeding with the workstation launch.

To take Cloudflare Turnstile into use, go to your Cloudflare dashboard, open the Application security accordion, select Turnstile and click on Add widget:

Turnstile 1

Enter a widget name, e.g. Deskterm, and click + Add Hostnames:

Turnstile 2

Enter or select the necessary hostnames as needed, e.g. deskterm.com and test.deskterm.com in the case of Deskterm. Once you are done click Add:

Turnstile 3

Now select Managed for the Widget Mode and click Create:

Turnstile 4

You should now see your Cloudlare Turnstile's Site key and Secret key. Note them down and store them in a secure location, e.g. your password manager.

Turnstile 5

Now in your GitLab CI, create a new CI variable with the Key set to TURNSTILE_SITE_KEY. Set the Visibility to Masked and hidden and enter the site key you saw on Cloudflare above into the variable's Value field. Then click Add variable:

Turnstile 6

Create a new CI variable with the Key set to TURNSTILE_SECRET_KEY. Set the Visibility to Masked and hidden and enter the secret key you saw on Cloudflare above into the variable's Value field. Then click Add variable:

Turnstile 7

Configuration

Fly.io API Token

The Deskterm backend needs a Fly.io API token to orchestrate user workstations. Update runtime.exs to configure the Fly.io API token. Place the below code just above the if config_env() == :prod do block in runtime.exs:

runtime.exs
config :deskterm,
       :fly_api_token,
       System.get_env("FLY_API_TOKEN") ||
         raise("""
         Environment variable FLY_API_TOKEN is missing.
         You can create one in your Fly.io account.
         """)

Turnstile Configuration

To configure Cloudflare Turnstile for production, add the below code to the bottom of the if config_env() == :prod do block in runtime.exs:

runtime.exs
turnstile_site_key =
  System.get_env("TURNSTILE_SITE_KEY") ||
    raise """
    Environment variable TURNSTILE_SITE_KEY is missing.
    You can find it from your Cloudflare account.
    """

turnstile_secret_key =
  System.get_env("TURNSTILE_SECRET_KEY") ||
    raise """
    Environment variable TURNSTILE_SECRET_KEY is missing.
    You can find it from your Cloudflare account.
    """

config :deskterm, :turnstile,
  site_key: turnstile_site_key,
  secret_key: turnstile_secret_key

For development and test environments, Cloudflare provides test keys that always pass verification. Add the below configuration to dev.exs and test.exs:

dev.exs / test.exs
config :deskterm, :turnstile,
  site_key: "1x00000000000000000000AA",
  secret_key: "1x0000000000000000000000000000000AA"

Flyctl in Docker

The Deskterm backend needs the Fly.io CLI tool (flyctl) to execute commands on Fly.io machines via SSH. Update the Dockerfile to install curl and flyctl in the runner image:

Dockerfile
RUN apt-get update -y && \
  apt-get install -y libstdc++6 openssl libncursesw6 locales ca-certificates \
  curl && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Install flyctl
RUN FLYCTL_INSTALL=/usr/local sh -c "$(curl -fsSL https://fly.io/install.sh)"

CI/CD Deploy Pipelines

Update deploy-test-environment.yml to add a migration step before deploying and pass the FLY_API_TOKEN, TURNSTILE_SITE_KEY, and TURNSTILE_SECRET_KEY environment variables to both the migration and run commands:

deploy-test-environment.yml
    - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker run --rm --env SECRET_KEY_BASE=$SECRET_KEY_BASE_TEST --env DATABASE_URL=$DATABASE_URL_TEST --env SUPABASE_URL=$SUPABASE_URL_TEST --env SUPABASE_API_KEY=$SUPABASE_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:test-environment /app/bin/migrate"

Update the docker run command to include the new environment variables:

deploy-test-environment.yml
    - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker run -d --publish 80:80 --name test-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_TEST --env DATABASE_URL=$DATABASE_URL_TEST --env PHX_HOST=test.deskterm.com --env SUPABASE_URL=$SUPABASE_URL_TEST --env SUPABASE_API_KEY=$SUPABASE_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:test-environment"

Update deploy-production-environment.yml with the same changes for the production environment:

deploy-production-environment.yml
    - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker run --rm --env SECRET_KEY_BASE=$SECRET_KEY_BASE_PRODUCTION --env DATABASE_URL=$DATABASE_URL_PRODUCTION --env SUPABASE_URL=$SUPABASE_URL_PRODUCTION --env SUPABASE_API_KEY=$SUPABASE_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:production-environment /app/bin/migrate"

Update the docker run command to include the new environment variables:

deploy-production-environment.yml
    - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker run -d --publish 80:80 --name production-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_PRODUCTION --env DATABASE_URL=$DATABASE_URL_PRODUCTION --env PHX_HOST=deskterm.com --env SUPABASE_URL=$SUPABASE_URL_PRODUCTION --env SUPABASE_API_KEY=$SUPABASE_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:production-environment"

Tests

E2E Test

We will create an E2E test in Qase. Add a new test case to the Common suite named Workstation:

Workstation Test 1

Workstation Test 2

Workstation Test 3

Automated Tests

Update test_helper.exs to define mocks for the Fly.io API and Turnstile API and configure them:

test_helper.exs
Mox.defmock(DesktermWeb.FlyAPIMock,
  for: DesktermWeb.FlyAPI
)

Mox.defmock(DesktermWeb.TurnstileAPIMock,
  for: DesktermWeb.TurnstileAPI
)

Application.put_env(
  :deskterm,
  :fly_api,
  DesktermWeb.FlyAPIMock
)

Application.put_env(
  :deskterm,
  :turnstile_api,
  DesktermWeb.TurnstileAPIMock
)

In the test/support directory, create a new file named secret_atoms.ex and populate it with the below content:

secret_atoms.ex
defmodule DesktermWeb.TestSecretAtoms do
  def endpoint_secret, do: "Z7uPiIDI48"
  def fly_api_token, do: "5olf3w83"
  def repo_url, do: "ecto://pYCJeLeZg4:NFIaHye8s8@eqz6xh7Ryy/GQHP6lLimj"
  def supabase_api_key, do: "mr62bOev2h"
  def ws_auth_token, do: "srl2seixcu"
end

In the test/support/authentication directory, create a new file named user_atoms.ex and populate it with the below content:

user_atoms.ex
defmodule DesktermWeb.TestUserAtoms do
  @user_id "0e13a01f-4625-1209-a000-e22ad7ba0000"
  @fly_app_name "user-JVzuEaNOgCRebgE1"
  @fly_machine_id "ak7yud6oov9gykxj"

  @session %{
    token: "lwn3j89n",
    refresh_token: "cu6dbswg",
    user: %{
      id: @user_id
    }
  }

  @otp_code %{
    "1" => "1",
    "2" => "2",
    "3" => "3",
    "4" => "4",
    "5" => "5",
    "6" => "6"
  }

  def session, do: @session
  def user_id, do: @user_id
  def fly_app_name, do: @fly_app_name
  def fly_machine_id, do: @fly_machine_id
  def otp_code, do: @otp_code
end

Update test_utilities.ex to replace the contents with the below. This refactors the authentication test utilities to use the new TestUserAtoms module (which includes a user map with an id for profile creation):

test_utilities.ex
defmodule DesktermWeb.TestUtilities do
  @moduledoc false
  import Mox

  alias DesktermWeb.TestOAuthAtoms
  alias DesktermWeb.TestUserAtoms

  @email "[email protected]"
  @otp_code "123456"

  @otp_credentials %{
    email: @email,
    options: %{
      should_create_user: true
    }
  }

  @otp_params %{
    email: @email,
    token: @otp_code,
    type: :magiclink
  }

  def when_get_client_fails do
    expect(
      DesktermWeb.SupabaseClientAPIMock,
      :get_client,
      fn -> {:error, :timeout} end
    )
  end

  def when_get_client_succeeds do
    expect(
      DesktermWeb.SupabaseClientAPIMock,
      :get_client,
      fn -> {:ok, %Supabase.Client{}} end
    )
  end

  def when_oauth_login_fails do
    expected_credentials = TestOAuthAtoms.oauth_credentials()

    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :sign_in_with_oauth,
      fn _client, ^expected_credentials ->
        {:error, :timeout}
      end
    )
  end

  def when_oauth_login_succeeds do
    expected_credentials = TestOAuthAtoms.oauth_credentials()

    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :sign_in_with_oauth,
      fn _client, ^expected_credentials ->
        {:ok,
         %{
           url: TestOAuthAtoms.oauth_url(),
           code_verifier: TestOAuthAtoms.code_verifier()
         }}
      end
    )
  end

  def when_otp_login_fails do
    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :sign_in_with_otp,
      fn _client, @otp_credentials ->
        {:error, :timeout}
      end
    )
  end

  def when_otp_login_succeeds do
    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :sign_in_with_otp,
      fn _client, @otp_credentials ->
        :ok
      end
    )
  end

  def when_verification_fails do
    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:error, :timeout}
      end
    )
  end

  def when_verification_succeeds do
    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:ok, TestUserAtoms.session()}
      end
    )
  end

  def when_code_exchange_fails do
    expected_code = TestOAuthAtoms.code()

    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:error, :timeout}
      end
    )
  end

  def when_code_exchange_succeeds do
    expected_code = TestOAuthAtoms.code()

    expect(
      DesktermWeb.SupabaseAuthAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:ok, TestUserAtoms.session()}
      end
    )
  end
end

In the test/support directory, create a new file named fly_utilities.ex and populate it with the below content:

fly_utilities.ex
defmodule DesktermWeb.TestFlyUtilities do
  import Mox

  def when_fly_app_is_created do
    when_create_app_succeeds()
    when_allocate_ip_succeeds("shared_v4")
    when_allocate_ip_succeeds("v6")
  end

  def when_machine_is_created do
    when_create_volume_succeeds()
    when_create_machine_succeeds()
    when_wait_for_machine_succeeds()
  end

  def when_create_app_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_app,
      fn _app_name ->
        :ok
      end
    )
  end

  def when_create_app_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_app,
      fn _app_name ->
        :error
      end
    )
  end

  def when_allocate_ip_succeeds(type) do
    expect(
      DesktermWeb.FlyAPIMock,
      :allocate_ip_address,
      fn _app_name, ^type ->
        :ok
      end
    )
  end

  def when_allocate_ipv4_address_fails do
    when_allocate_ip_fails("shared_v4")
  end

  def when_allocate_ipv4_address_succeeds do
    when_allocate_ip_succeeds("shared_v4")
  end

  def when_allocate_ipv6_address_fails do
    when_allocate_ip_fails("v6")
  end

  defp when_allocate_ip_fails(type) do
    expect(
      DesktermWeb.FlyAPIMock,
      :allocate_ip_address,
      fn _app_name, ^type ->
        :error
      end
    )
  end

  def when_create_volume_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_volume,
      fn _app_name, _volume ->
        {:ok, "volume-id"}
      end
    )
  end

  def when_create_volume_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_volume,
      fn _app_name, _volume ->
        :error
      end
    )
  end

  def when_create_machine_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_machine,
      fn _app_name, _workstation ->
        {:ok, "machine-id"}
      end
    )
  end

  def when_create_machine_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :create_machine,
      fn _app_name, _workstation ->
        :error
      end
    )
  end

  def when_start_machine_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :start_machine,
      fn _app_name, _machine_id ->
        :ok
      end
    )
  end

  def when_start_machine_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :start_machine,
      fn _app_name, _machine_id ->
        :error
      end
    )
  end

  def when_wait_for_machine_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :wait_for_machine,
      fn _app_name, _machine_id ->
        :ok
      end
    )
  end

  def when_wait_for_machine_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :wait_for_machine,
      fn _app_name, _machine_id ->
        :error
      end
    )
  end

  def when_stop_machine_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :stop_machine,
      fn _app_name, _machine_id ->
        :ok
      end
    )
  end

  def when_stop_machine_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :stop_machine,
      fn _app_name, _machine_id ->
        :error
      end
    )
  end

  def when_delete_app_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :delete_app,
      fn _app_name ->
        :error
      end
    )
  end

  def when_delete_app_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :delete_app,
      fn _app_name ->
        :ok
      end
    )
  end

  def when_execute_command_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_async,
      fn _app_name, _command ->
        :error
      end
    )
  end

  def when_execute_command_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_async,
      fn _app_name, _command ->
        {:ok, Task.async(fn -> Process.sleep(:infinity) end)}
      end
    )
  end

  def when_execute_command_completes(times \\ 1) do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_async,
      times,
      fn _app_name, command ->
        send(command.caller, {:stream_complete, :ok})
        {:ok, Task.async(fn -> Process.sleep(:infinity) end)}
      end
    )
  end

  def when_execute_command_sync_succeeds do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_sync,
      fn _app_name, _command ->
        :ok
      end
    )
  end

  def when_stream_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_async,
      fn _app_name, command ->
        send(command.caller, {:stream_complete, :error})
        {:ok, Task.async(fn -> Process.sleep(:infinity) end)}
      end
    )
  end

  def when_stream_updates(update) do
    expect(
      DesktermWeb.FlyAPIMock,
      :execute_command_async,
      fn _app_name, command ->
        send(command.caller, {:stream_update, update})
        {:ok, Task.async(fn -> Process.sleep(:infinity) end)}
      end
    )
  end
end

In the test/support directory, create a new file named launch_utilities.ex and populate it with the below content:

launch_utilities.ex
defmodule DesktermWeb.TestLaunchUtilities do
  use DesktermWeb.ConnCase
  import Phoenix.LiveViewTest
  import Mox

  @turnstile_token "9td4zkentloxmsj7"

  def start_workstation(view, is_bot \\ false) do
    if is_bot do
      when_turnstile_fails()
    else
      when_turnstile_succeeds()
    end

    render_click(view, "select_workstation", %{ws_name: "Chrome"})

    render_submit(view, "start_workstation", %{
      ws_location: "arn",
      turnstile_token: @turnstile_token
    })
  end

  def assert_launch_aborted(view) do
    assert_push_event(view, "show-toast", %{})
    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#loading-workstation")
  end

  def assert_launch_not_aborted(view) do
    refute_push_event(view, "show-toast", %{})
    assert has_element?(view, "#loading-workstation")
    refute has_element?(view, "#overview")
    refute has_element?(view, "#toast-error")
  end

  defp when_turnstile_succeeds do
    expect(
      DesktermWeb.TurnstileAPIMock,
      :verify,
      fn @turnstile_token ->
        :ok
      end
    )
  end

  defp when_turnstile_fails do
    expect(
      DesktermWeb.TurnstileAPIMock,
      :verify,
      fn @turnstile_token ->
        :error
      end
    )
  end
end

In the test/support directory, create a new file named censorship_utilities.ex and populate it with the below content:

censorship_utilities.ex
defmodule DesktermWeb.TestEnvironment do
  defstruct app: nil, key: nil, value: nil
end

defmodule DesktermWeb.TestCensorshipUtilities do
  import ExUnit.Callbacks

  alias DesktermWeb.TestEnvironment
  alias DesktermWeb.TestSecretAtoms

  def initialize_environment do
    whenever_environment_is(fly_api_token_env())
    whenever_environment_is(supabase_client_env())
    whenever_environment_is(endpoint_secret_env())
    whenever_environment_is(repo_url_env())
  end

  def redacted, do: "******"

  def socket(ws_auth_token \\ nil) do
    %{assigns: %{ws_auth_token: ws_auth_token}}
  end

  def get_uncensored_secrets do
    Enum.join(
      [
        TestSecretAtoms.fly_api_token(),
        TestSecretAtoms.ws_auth_token(),
        TestSecretAtoms.supabase_api_key(),
        TestSecretAtoms.endpoint_secret(),
        TestSecretAtoms.repo_url()
      ],
      " | "
    )
  end

  def get_censored_secrets do
    Enum.join(
      [
        redacted() <> "83",
        redacted() <> "ixcu",
        redacted() <> "ev2h",
        redacted() <> "DI48",
        redacted() <>
          redacted() <>
          redacted() <>
          redacted() <>
          redacted() <>
          redacted() <>
          redacted() <>
          redacted() <>
          "mj"
      ],
      " | "
    )
  end

  defp fly_api_token_env do
    %TestEnvironment{
      app: :deskterm,
      key: :fly_api_token,
      value: TestSecretAtoms.fly_api_token()
    }
  end

  defp supabase_client_env do
    %TestEnvironment{
      app: :deskterm,
      key: Deskterm.Supabase.Client,
      value: [api_key: TestSecretAtoms.supabase_api_key()]
    }
  end

  defp endpoint_secret_env do
    %TestEnvironment{
      app: :deskterm,
      key: DesktermWeb.Endpoint,
      value: [secret_key_base: TestSecretAtoms.endpoint_secret()]
    }
  end

  defp repo_url_env do
    %TestEnvironment{
      app: :deskterm,
      key: Deskterm.Repo,
      value: [url: TestSecretAtoms.repo_url()]
    }
  end

  defp whenever_environment_is(environment) do
    previous_value = Application.get_env(environment.app, environment.key)
    Application.put_env(environment.app, environment.key, environment.value)

    on_exit(fn ->
      Application.put_env(environment.app, environment.key, previous_value)
    end)
  end
end

Replace the contents of app_utilities.ex with the below:

app_utilities.ex
defmodule DesktermWeb.TestAppUtilities do
  use DesktermWeb.ConnCase
  import Phoenix.LiveViewTest

  alias Deskterm.Repo
  alias Deskterm.Schema.Profile
  alias DesktermWeb.TestAppAtoms
  alias DesktermWeb.TestUserAtoms
  alias DesktermWeb.TestUtilities

  def when_is_unauthenticated(conn) do
    {:ok, view, _html} = live(conn, TestAppAtoms.path())
    render_click(view, "continue_without_account")
    render_click(view, "confirm_without_account")

    view
  end

  def when_is_new_user(conn) do
    %Profile{}
    |> Profile.changeset(%{
      user_id: TestUserAtoms.user_id(),
      subscription_type: :basic
    })
    |> Repo.insert!()

    when_otp_login_succeeds(conn)
  end

  def when_is_old_user(conn, has_workstation \\ false) do
    profile =
      %Profile{}
      |> Profile.changeset(%{
        user_id: TestUserAtoms.user_id(),
        subscription_type: :basic,
        fly_app_name: TestUserAtoms.fly_app_name(),
        fly_machine_id: TestUserAtoms.fly_machine_id()
      })
      |> Repo.insert!()

    if has_workstation do
      Deskterm.Workstation.ensure_workstation(profile, %{name: "Chrome"})
    end

    when_otp_login_succeeds(conn)
  end

  defp when_otp_login_succeeds(conn) do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_succeeds()
    TestUtilities.when_verification_succeeds()

    {:ok, view, _html} = live(conn, TestAppAtoms.path())
    render_click(view, "login_with_otp", %{email: "[email protected]"})
    assert_push_event(view, "show-toast", %{})

    render_click(view, "verify_otp_code", %{code: TestUserAtoms.otp_code()})
    assert_push_event(view, "show-toast", %{})
    assert_push_event(view, "show-animation", %{})

    view
  end
end

In the test/deskterm directory, create a new file named profile_test.exs and populate it with the below content:

profile_test.exs
defmodule Deskterm.ProfileTest do
  use Deskterm.DataCase, async: true

  alias Deskterm.Profile
  alias Deskterm.Schema.Profile, as: ProfileSchema
  alias DesktermWeb.TestUserAtoms

  @fly_volume_id "vol_CnGy1QUofswXZs6o"
  @fly_volume_region "arn"

  test "inserts profile record when user does not exist" do
    user_id = Ecto.UUID.generate()

    Profile.ensure_profile(user_id)
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.user_id == user_id
  end

  test "does not insert new profile record when user already exists" do
    user_id = Ecto.UUID.generate()

    Profile.ensure_profile(user_id)
    Profile.ensure_profile(user_id)

    assert Repo.aggregate(ProfileSchema, :count) == 1
  end

  test "updates profile record with app when no app was set previously" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    Profile.ensure_app_name(profile, TestUserAtoms.fly_app_name())
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_app_name == TestUserAtoms.fly_app_name()
  end

  test "does not overwrite existing app" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    {:ok, profile} =
      Profile.ensure_app_name(profile, TestUserAtoms.fly_app_name())

    Profile.ensure_app_name(profile, "user-PcoYsKR84qyVgudJ")
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_app_name == TestUserAtoms.fly_app_name()
  end

  test "updates profile record with volume when no volume was set previously" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    Profile.ensure_volume(profile, %{
      id: @fly_volume_id,
      region: @fly_volume_region
    })

    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_volume_id == @fly_volume_id
    assert profile.fly_volume_region == @fly_volume_region
  end

  test "does not overwrite existing volume" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    {:ok, profile} =
      Profile.ensure_volume(profile, %{
        id: @fly_volume_id,
        region: @fly_volume_region
      })

    Profile.ensure_volume(profile, %{id: "vol_QWOj9jKBFcpsdSqn", region: "fra"})
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_volume_id == @fly_volume_id
    assert profile.fly_volume_region == @fly_volume_region
  end

  test "updates profile record with machine id when no machine id was set previously" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    Profile.ensure_machine_id(profile, TestUserAtoms.fly_machine_id())
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_machine_id == TestUserAtoms.fly_machine_id()
  end

  test "does not overwrite existing machine id" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    {:ok, profile} =
      Profile.ensure_machine_id(profile, TestUserAtoms.fly_machine_id())

    Profile.ensure_machine_id(profile, "mry4i0kg6f2jg7qi")
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.fly_machine_id == TestUserAtoms.fly_machine_id()
  end

  test "does not lock profile for free user" do
    assert :ok = Profile.lock(%ProfileSchema{}, :free_user)

    assert Repo.aggregate(ProfileSchema, :count) == 0
  end

  test "locks profile when unlocked" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)

    assert :ok = Profile.lock(profile, :new_user)
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.launching_at != nil
  end

  test "does not lock profile when it was recently locked" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    :ok = Profile.lock(profile, :new_user)

    assert :error = Profile.lock(profile, :new_user)
  end

  test "locks profile when previous lock has expired" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    expired_time = DateTime.add(DateTime.utc_now(), -15, :minute)

    from(p in ProfileSchema, where: p.id == ^profile.id)
    |> Repo.update_all(set: [launching_at: expired_time])

    assert :ok = Profile.lock(profile, :new_user)
  end

  test "unlocking free user is a no-op" do
    assert :ok = Profile.unlock(%ProfileSchema{}, :free_user)
  end

  test "unlocks locked profile" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    :ok = Profile.lock(profile, :new_user)
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    Profile.unlock(profile, :new_user)
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert profile.launching_at == nil
  end
end

In the test/deskterm directory, create a new file named workstation_test.exs and populate it with the below content:

workstation_test.exs (schema)
defmodule Deskterm.WorkstationTest do
  use Deskterm.DataCase, async: true

  alias Deskterm.Profile
  alias Deskterm.Schema.Workstation, as: WorkstationSchema
  alias Deskterm.Workstation

  @ws_name "chrome"

  test "inserts workstation record if workstation does not exist yet" do
    profile = get_profile()

    assert :ok = Workstation.ensure_workstation(profile, get_workstation())

    assert Repo.get_by!(WorkstationSchema,
             profile_id: profile.id,
             ws_name: @ws_name
           )
  end

  test "does not insert workstation if workstation already exists" do
    profile = get_profile()

    :ok = Workstation.ensure_workstation(profile, get_workstation())
    :ok = Workstation.ensure_workstation(profile, get_workstation())

    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  defp get_profile do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    profile
  end

  defp get_workstation(name \\ @ws_name) do
    %{name: name}
  end
end

In the test/deskterm directory, create a new file named session_test.exs and populate it with the below content:

session_test.exs (schema)
defmodule Deskterm.SessionTest do
  use Deskterm.DataCase, async: true

  alias Deskterm.Session

  test "gets orphaned sessions started before threshold" do
    {:ok, very_old_session} = get_session(30)
    {:ok, _recent_session} = get_session(5)

    threshold = DateTime.add(DateTime.utc_now(), -5, :minute)
    orphaned = Session.get_orphaned_sessions(threshold)

    assert length(orphaned) == 1
    assert hd(orphaned).id == very_old_session.id
  end

  test "does not consider ended sessions to be orphaned" do
    {:ok, session} = get_session(30)
    Session.end_session(session)

    threshold = DateTime.add(DateTime.utc_now(), -5, :minute)
    orphaned = Session.get_orphaned_sessions(threshold)

    assert orphaned == []
  end

  test "gets count of active free sessions" do
    {:ok, _} = get_session(15)
    {:ok, _} = get_session(10)
    {:ok, _} = get_session(5)

    assert Session.get_active_free_sessions_count() == 3
  end

  test "does not count ended sessions" do
    {:ok, session} = get_session(5)
    Session.end_session(session)

    assert Session.get_active_free_sessions_count() == 0
  end

  test "does not count non-free sessions" do
    {:ok, _} = get_session(5, "new_user")

    assert Session.get_active_free_sessions_count() == 0
  end

  defp get_session(started_minutes_ago, user_type \\ "free_user") do
    started_at =
      DateTime.utc_now()
      |> DateTime.add(-started_minutes_ago, :minute)

    attributes = %{
      user_type: user_type,
      fly_app_name: "free-38c915ee0f38b834",
      ws_name: "chrome",
      ip_address: "127.0.0.1",
      started_at: started_at
    }

    Session.create(attributes)
  end
end

In the test/deskterm directory, create a new file named session_cleanup_test.exs and populate it with the below content:

session_cleanup_test.exs
defmodule Deskterm.SessionCleanupTest do
  use Deskterm.DataCase, async: true

  import Mox

  alias Deskterm.Schema.Session, as: SessionSchema
  alias Deskterm.Session
  alias Deskterm.SessionCleanup
  alias DesktermWeb.TestFlyUtilities

  setup :verify_on_exit!

  test "works when there are no orphaned sessions" do
    SessionCleanup.run()
  end

  test "does not end session for free user when Fly app deletion fails" do
    TestFlyUtilities.when_delete_app_fails()
    {:ok, session} = get_session("free_user")

    SessionCleanup.run()

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at == nil
  end

  test "ends session and deletes Fly app for free user" do
    TestFlyUtilities.when_delete_app_succeeds()
    {:ok, session} = get_session("free_user")

    SessionCleanup.run()

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at != nil
  end

  test "ends session for non-free user without deleting Fly app" do
    {:ok, session} = get_session("old_user")

    SessionCleanup.run()

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at != nil
  end

  defp get_session(user_type) do
    started_at =
      DateTime.utc_now()
      |> DateTime.add(-30, :minute)

    Session.create(%{
      user_type: user_type,
      fly_app_name: "38c915ee0f38b834",
      ws_name: "chrome",
      ip_address: "127.0.0.1",
      started_at: started_at
    })
  end
end

In the test/deskterm_web/live directory, create a new file named launch_verification_test.exs and populate it with the below content:

launch_verification_test.exs
defmodule DesktermWeb.LaunchVerificationTest do
  use DesktermWeb.ConnCase

  import Mox

  alias Deskterm.Session
  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestLaunchUtilities

  setup :verify_on_exit!

  test "aborts launch when turnstile verification fails", %{conn: conn} do
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view, true)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "aborts launch when free user capacity is reached", %{conn: conn} do
    create_free_sessions(30)
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  defp create_free_sessions(count) do
    for _ <- 1..count do
      {:ok, _} =
        Session.create(%{
          user_type: "free_user",
          fly_app_name:
            "free-#{:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)}",
          ws_name: "chrome",
          ip_address: "127.0.0.1",
          started_at: DateTime.utc_now()
        })
    end
  end
end

In the test/deskterm_web/live directory, create a new file named censorship_test.exs and populate it with the below content:

censorship_test.exs
defmodule DesktermWeb.CensorshipTest do
  use ExUnit.Case, async: true

  alias DesktermWeb.Censorship
  alias DesktermWeb.TestCensorshipUtilities
  alias DesktermWeb.TestSecretAtoms

  setup do
    TestCensorshipUtilities.initialize_environment()
  end

  test "works on empty input" do
    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), "") == ""
    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), []) == []
  end

  test "censors secret when present" do
    uncensored = TestSecretAtoms.fly_api_token()
    censored = TestCensorshipUtilities.redacted() <> "83"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             uncensored
           ) == censored

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             uncensored
           ]) == [censored]
  end

  test "censors secret when present in the beginning of a line" do
    uncensored = TestSecretAtoms.fly_api_token() <> " middle end"
    censored = TestCensorshipUtilities.redacted() <> "83 middle end"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             uncensored
           ) == censored

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             uncensored
           ]) == [censored]
  end

  test "censors secret when present in the middle of a line" do
    uncensored = "start " <> TestSecretAtoms.fly_api_token() <> " end"
    censored = "start " <> TestCensorshipUtilities.redacted() <> "83 end"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             uncensored
           ) == censored

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             uncensored
           ]) == [censored]
  end

  test "censors secret when present at the end of a line" do
    uncensored = "start middle " <> TestSecretAtoms.fly_api_token()
    censored = "start middle " <> TestCensorshipUtilities.redacted() <> "83"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             uncensored
           ) == censored

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             uncensored
           ]) == [censored]
  end

  test "censors secret when present multiple times" do
    uncensored =
      TestSecretAtoms.fly_api_token() <>
        TestSecretAtoms.fly_api_token() <>
        " middle " <> TestSecretAtoms.fly_api_token()

    censored =
      TestCensorshipUtilities.redacted() <>
        "83" <>
        TestCensorshipUtilities.redacted() <>
        "83 middle " <> TestCensorshipUtilities.redacted() <> "83"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             uncensored
           ) == censored

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             uncensored
           ]) == [censored]
  end

  test "censors secret when present on multiple lines" do
    first_uncensored_line =
      TestSecretAtoms.fly_api_token() <>
        TestSecretAtoms.fly_api_token() <>
        "\n" <> TestSecretAtoms.fly_api_token()

    second_uncensored_line =
      "start " <>
        TestSecretAtoms.fly_api_token() <>
        " middle " <> TestSecretAtoms.fly_api_token()

    first_censored_line =
      TestCensorshipUtilities.redacted() <>
        "83" <>
        TestCensorshipUtilities.redacted() <>
        "83\n" <> TestCensorshipUtilities.redacted() <> "83"

    second_censored_line =
      "start " <>
        TestCensorshipUtilities.redacted() <>
        "83 middle " <> TestCensorshipUtilities.redacted() <> "83"

    assert Censorship.censor_secrets(
             TestCensorshipUtilities.socket(),
             first_uncensored_line <> "\n" <> second_uncensored_line
           ) == first_censored_line <> "\n" <> second_censored_line

    assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
             first_uncensored_line,
             second_uncensored_line
           ]) == [first_censored_line, second_censored_line]
  end

  test "censors secrets from multiple sources" do
    uncensored = TestCensorshipUtilities.get_uncensored_secrets()
    censored = TestCensorshipUtilities.get_censored_secrets()
    uncensored_list = String.split(uncensored, " | ")
    censored_list = String.split(censored, " | ")
    socket = TestCensorshipUtilities.socket(TestSecretAtoms.ws_auth_token())

    assert Censorship.censor_secrets(socket, uncensored) == censored
    assert Censorship.censor_secrets(socket, uncensored_list) == censored_list
  end
end

In the test/deskterm_web/live directory, create a new file named ip_address_test.exs and populate it with the below content:

ip_address_test.exs
defmodule DesktermWeb.IPAddressTest do
  use ExUnit.Case, async: true

  alias DesktermWeb.IPAddress

  test "gets proxied IP address when available" do
    socket = get_socket(x_headers: [{"x-forwarded-for", "203.0.113.1"}])

    assert IPAddress.get_client_ip_address(socket) == "203.0.113.1"
  end

  test "gets first IP from x-forwarded-for chain" do
    socket =
      get_socket(x_headers: [{"x-forwarded-for", "203.0.113.1, 10.0.0.1, 172.16.0.1"}])

    assert IPAddress.get_client_ip_address(socket) == "203.0.113.1"
  end

  test "gets direct IP address when proxied IP address is not available" do
    socket = get_socket(x_headers: [])

    assert IPAddress.get_client_ip_address(socket) == "127.0.0.1"
  end

  test "normalizes IPv4-mapped IPv6 address to IPv4" do
    socket =
      get_socket(
        x_headers: [],
        address: {0, 0, 0, 0, 0, 65_535, 44_049, 1}
      )

    assert IPAddress.get_client_ip_address(socket) == "172.17.0.1"
  end

  defp get_socket(opts) do
    x_headers = Keyword.get(opts, :x_headers, [])
    address = Keyword.get(opts, :address, {127, 0, 0, 1})

    %Phoenix.LiveView.Socket{
      private: %{
        connect_info: %{
          x_headers: x_headers,
          peer_data: %{address: address}
        }
      }
    }
  end
end

In the test/deskterm_web/live directory, create a new file named session_test.exs and populate it with the below content:

session_test.exs (live)
defmodule DesktermWeb.SessionTest do
  use Deskterm.DataCase, async: true

  import Mox

  alias Deskterm.Schema.Session, as: SessionSchema
  alias Deskterm.Session
  alias DesktermWeb.TestFlyUtilities

  @fly_machine_id "uc5q9oh95ia8bibj"

  @free_user_app_name "free-6odlvsl1s2wokcvs"
  @old_user_app_name "user-h4cctm546bmgmj2x"

  setup :verify_on_exit!

  test "does not end session for free user when Fly app deletion fails" do
    TestFlyUtilities.when_delete_app_fails()
    {:ok, session} = get_session("free_user")

    DesktermWeb.Session.end_session(@free_user_app_name, session)

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at == nil
  end

  test "ends session for free user when fly app deletion succeeds" do
    TestFlyUtilities.when_delete_app_succeeds()
    {:ok, session} = get_session("free_user")

    DesktermWeb.Session.end_session(@free_user_app_name, session)

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at != nil
  end

  test "does not end session for non-free user when stopping machine fails" do
    TestFlyUtilities.when_execute_command_sync_succeeds()
    TestFlyUtilities.when_stop_machine_fails()
    {:ok, session} = get_session("old_user", @fly_machine_id)

    DesktermWeb.Session.end_session(@old_user_app_name, session)

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at == nil
  end

  test "ends session for non-free user when stopping machine succeeds" do
    TestFlyUtilities.when_execute_command_sync_succeeds()
    TestFlyUtilities.when_stop_machine_succeeds()
    {:ok, session} = get_session("old_user", @fly_machine_id)

    DesktermWeb.Session.end_session(@old_user_app_name, session)

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at != nil
  end

  test "ends session for non-free user when machine does not exist" do
    {:ok, session} = get_session("old_user")

    DesktermWeb.Session.end_session(@old_user_app_name, session)

    session = Repo.get!(SessionSchema, session.id)
    assert session.ended_at != nil
  end

  defp get_session(user_type, fly_machine_id \\ nil) do
    started_at =
      DateTime.utc_now()
      |> DateTime.add(-30, :minute)

    attributes =
      %{
        user_type: user_type,
        fly_app_name: "free-38c915ee0f38b834",
        ws_name: "chrome",
        ip_address: "203.0.113.1",
        started_at: started_at,
        fly_machine_id: fly_machine_id
      }

    Session.create(attributes)
  end
end

In the test/deskterm_web/live directory, create a new file named ui_log_test.exs and populate it with the below content:

ui_log_test.exs
defmodule DesktermWeb.UILogStreamTest do
  use DesktermWeb.ConnCase

  import Mox

  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities

  setup do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    verify_on_exit!()
  end

  @error_message "Timed out waiting for log-update event containing:"

  @stream_updates [
    "Using default tag: latest",
    "latest: Pulling from linuxserver/chrome"
  ]

  test "aborts launch when stream fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_stream_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "updates ui log on stream update", %{
    conn: conn
  } do
    TestFlyUtilities.when_stream_updates(@stream_updates)
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    assert_ui_log_contains(view, Enum.join(@stream_updates, "\n"))
  end

  defp assert_ui_log_contains(view, expected) do
    %{proxy: {reference, _, _}} = view

    receive do
      {^reference, {:push_event, "log-update", %{log: log}}} ->
        unless log =~ expected do
          assert_ui_log_contains(view, expected)
        end
    after
      500 -> flunk(@error_message <> " #{inspect(expected)}")
    end
  end
end

In the test/deskterm_web/live directory, create a new file named new_user_test.exs and populate it with the below content:

new_user_test.exs
defmodule DesktermWeb.NewUserLaunchTest do
  use DesktermWeb.ConnCase

  import Mox

  alias Deskterm.Repo
  alias Deskterm.Schema.Profile
  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities
  alias DesktermWeb.TestUserAtoms

  setup :verify_on_exit!

  test "persists fly app name, volume, and machine id on first workstation launch",
       %{
         conn: conn
       } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(6)
    view = TestAppUtilities.when_is_new_user(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
    profile = Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())
    assert profile.fly_app_name != nil
    assert profile.fly_volume_id != nil
    assert profile.fly_machine_id != nil
  end
end

In the test/deskterm_web/live/workstation directory, create a new file named workstation_test.exs and populate it with the below content:

workstation_test.exs
defmodule DesktermWeb.WorkstationTest do
  use DesktermWeb.ConnCase

  import Mox

  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities

  setup :verify_on_exit!

  test "aborts launch when creating volume fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_create_volume_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when creating volume succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when creating machine fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_create_volume_succeeds()
    TestFlyUtilities.when_create_machine_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when creating machine succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when starting machine fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_start_machine_fails()
    view = TestAppUtilities.when_is_old_user(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when starting machine succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_old_user(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when waiting for machine fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_create_volume_succeeds()
    TestFlyUtilities.when_create_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when waiting for machine succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end
end

In the test/deskterm_web/live/fly_app directory, create a new file named fly_app_test.exs and populate it with the below content:

fly_app_test.exs
defmodule DesktermWeb.FlyAppTest do
  use DesktermWeb.ConnCase

  import Mox

  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities

  setup :verify_on_exit!

  test "aborts launch when creating fly app fails", %{conn: conn} do
    TestFlyUtilities.when_create_app_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "aborts launch when allocating ipv4 address fails", %{conn: conn} do
    TestFlyUtilities.when_create_app_succeeds()
    TestFlyUtilities.when_allocate_ipv4_address_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "aborts launch when allocating ipv6 address fails", %{conn: conn} do
    TestFlyUtilities.when_create_app_succeeds()
    TestFlyUtilities.when_allocate_ipv4_address_succeeds()
    TestFlyUtilities.when_allocate_ipv6_address_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when fly app is created", %{conn: conn} do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_create_volume_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end
end

In the test/deskterm_web/live/container directory, create a new file named container_test.exs and populate it with the below content:

container_test.exs
defmodule DesktermWeb.ContainerTest do
  use DesktermWeb.ConnCase

  import Mox

  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities

  setup :verify_on_exit!

  test "aborts launch when waiting for docker fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when waiting for docker succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when configuring firewall fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes()
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when configuring firewall succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when pulling image fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when pulling image succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when starting workstation container fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(3)
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when starting workstation container succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(3)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when configuring firewall for old user fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes()
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_old_user(conn, true)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when configuring firewall for old user succeeds",
       %{
         conn: conn
       } do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes()
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_old_user(conn, true)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when resuming workstation container fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_old_user(conn, true)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when resuming workstation container succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_old_user(conn, true)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when writing proxy configuration fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(4)
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when writing proxy configuration succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(4)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end

  test "aborts launch when starting proxy container fails", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(5)
    TestFlyUtilities.when_execute_command_fails()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_aborted(view)
  end

  test "does not abort launch when starting proxy container succeeds", %{
    conn: conn
  } do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(5)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)

    TestLaunchUtilities.start_workstation(view)

    TestLaunchUtilities.assert_launch_not_aborted(view)
  end
end

Update workstations_test.exs to use the renamed ws_name parameter:

workstations_test.exs
render_click(view, "select_workstation", %{ws_name: "chrome"})

In the assets/test directory, create a new file named latency-measurement.test.ts and populate it with the below content:

latency-measurement.test.ts
import "@testing-library/jest-dom";

import {
  AutomaticBoxes,
  frankfurtID,
  frankfurtLatency,
  initializeAutomaticBoxes,
  initializeHTML,
  initializeLocationBoxes,
  LocationBoxes,
  stockholmID,
  stockholmLatency
} from "./utilities/latency-test-utils";
import { determineLatencies } from "../ts/workstations/latency-measurement";
import { pingLocations } from "../ts/workstations/ping";

jest.mock("../ts/workstations/ping");

describe("Latency Measurement", () => {
  const errorSVG = 'img[src="/images/icons/error.svg"]';
  const hidden = "hidden";
  const mockPingLocations =
    pingLocations as jest.MockedFunction<typeof pingLocations>;

  let automaticBoxes: AutomaticBoxes;
  let locationBoxes: LocationBoxes;

  beforeEach(() => {
    initializeHTML();

    automaticBoxes = initializeAutomaticBoxes();
    locationBoxes = initializeLocationBoxes();
    mockPingLocations.mockClear();
  });

  test("shows error when ping fails", () => {
    wheneverPingFails();

    determineLatencies(document.body);

    expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.vpnAutomaticSpinner.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Error');
  });

  test("shows latencies when ping succeeds", () => {
    wheneverPingSucceeds();

    determineLatencies(document.body);

    expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText.textContent)
      .toMatch(`${stockholmLatency} ms`);
    expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText.textContent)
      .toMatch(`${stockholmLatency} ms`);
    expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText.textContent)
      .toMatch(`${frankfurtLatency} ms`);
    expect(automaticBoxes.vpnAutomaticSpinner).toHaveClass(hidden);
    expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Stockholm');
  });

  test("updates location input when automatic is selected", () => {
    wheneverPingSucceeds();

    determineLatencies(document.body);

    const locationInput =
      document.getElementById("ws-location-input") as HTMLInputElement;
    expect(locationInput.value).toBe(stockholmID);
  });

  test("updates location input when better latency result arrives later", () => {
    wheneverLowestLatencyArrivesLater();

    determineLatencies(document.body);

    expect(automaticBoxes.wsAutomaticLatencyText.textContent)
      .toMatch(`${stockholmLatency} ms`);
    expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Stockholm');
  });

  function wheneverPingFails(): void {
    mockPingLocations.mockImplementation((_ids, callbacks) => {
      callbacks.onError({ locationID: stockholmID });
      callbacks.onError({ locationID: frankfurtID });
    });
  }

  function wheneverPingSucceeds(): void {
    mockPingLocations.mockImplementation((_ids, callbacks) => {
      callbacks.onResult({
        locationID: stockholmID,
        latency: stockholmLatency
      });
      callbacks.onResult({
        locationID: frankfurtID,
        latency: frankfurtLatency
      });
    });
  }

  function wheneverLowestLatencyArrivesLater(): void {
    mockPingLocations.mockImplementation((_ids, callbacks) => {
      callbacks.onResult({
        locationID: frankfurtID,
        latency: frankfurtLatency
      });
      callbacks.onResult({
        locationID: stockholmID,
        latency: stockholmLatency
      });
    });
  }
});

In the assets/test directory, create a new file named countdown.test.ts and populate it with the below content:

countdown.test.ts
import "@testing-library/jest-dom";
import { initializeCountdown } from "../ts/countdown";

describe("Countdown", () => {
  let container: HTMLElement;
  let label: HTMLElement;

  beforeEach(() => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date("2026-04-15T15:00:00Z"));

    document.body.innerHTML = `
      <div id="countdown-container" data-expiration="2026-04-15T15:05:00Z">
        <span data-countdown-label>05:00</span>
      </div>
    `;

    container = document.getElementById("countdown-container") as HTMLElement;
    label = container.querySelector("[data-countdown-label]") as HTMLElement;
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test("works on views without countdown", () => {
    document.body.innerHTML = ``;

    expect(() => initializeCountdown(document.body)).not.toThrow();
  });

  test("updates time shown after one second", () => {
    initializeCountdown(container);
    jest.advanceTimersByTime(1000);

    expect(label.textContent).toBe("04:59");
  });

  test("updates time shown as time passes", () => {
    initializeCountdown(container);
    jest.advanceTimersByTime(2.5 * 60 * 1000);

    expect(label.textContent).toBe("02:30");
  });

  test("shows zero when countdown expires", () => {
    initializeCountdown(container);
    jest.advanceTimersByTime(5 * 60 * 1000);

    expect(label.textContent).toBe("00:00");
  });

  test("continues to show zero when countdown is past expiration", () => {
    initializeCountdown(container);
    jest.advanceTimersByTime(10 * 60 * 1000);

    expect(label.textContent).toBe("00:00");
  });
});

In the assets/test directory, create a new file named loading-progress.test.ts and populate it with the below content:

loading-progress.test.ts
import "@testing-library/jest-dom";
import { initializeLoadingProgress } from "../ts/loading-progress";
import {
  advanceStep,
  initializeHTML,
} from "./utilities/loading-progress-test-utils";

describe("Loading Progress", () => {
  const LONG_DURATION = 100_000;

  let progressContainer: HTMLElement;
  let progressBar: HTMLElement;
  let progressLabel: HTMLElement;

  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();

    initializeHTML();

    progressContainer =
      document.getElementById("loading-workstation") as HTMLElement;
    progressBar = document.querySelector("[data-progress-bar]") as HTMLElement;
    progressLabel =
      document.querySelector("[data-progress-label]") as HTMLElement;

    initializeLoadingProgress(progressContainer);
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test("works on pages without loading progress", () => {
    document.body.innerHTML = ``;

    expect(() => initializeLoadingProgress(document.body)).not.toThrow();
  });

  test("approaches full section completion after long duration", () => {
    jest.advanceTimersByTime(LONG_DURATION);

    const progressBarWidth = parseFloat(progressBar.style.width);
    expect(progressBarWidth).toBeGreaterThan(24.5);
    expect(progressBarWidth).toBeLessThanOrEqual(25);
    expect(progressLabel.textContent).toBe("25%");
  });

  test("advances into next section on step progression", async () => {
    advanceStep(progressContainer);
    await Promise.resolve();
    jest.advanceTimersByTime(LONG_DURATION);

    const progressBarWidth = parseFloat(progressBar.style.width);
    expect(progressBarWidth).toBeGreaterThan(49.5);
    expect(progressBarWidth).toBeLessThanOrEqual(50);
    expect(progressLabel.textContent).toBe("50%");
  });

  test("approaches full last section after long duration", async () => {
    advanceStep(progressContainer);
    advanceStep(progressContainer);
    advanceStep(progressContainer);

    await Promise.resolve();
    jest.advanceTimersByTime(LONG_DURATION);

    const progressBarWidth = parseFloat(progressBar.style.width);
    expect(progressBarWidth).toBeGreaterThan(99.5);
    expect(progressBarWidth).toBeLessThanOrEqual(100);
    expect(progressLabel.textContent).toBe("100%");
  });
});

In the assets/test/utilities directory, create a new file named loading-progress-test-utils.ts and populate it with the below content:

loading-progress-test-utils.ts
export function initializeHTML(): void {
  document.body.innerHTML = `
    <div
      id="loading-workstation"
      data-step-index="0"
      data-total-steps="4"
    >
      <div data-progress-bar style="width: 0%"></div>
      <p data-progress-label>0%</p>
    </div>
  `;
}

export function advanceStep(container: HTMLElement): void {
  const currentStep = parseInt(container.dataset.stepIndex as string);
  container.dataset.stepIndex = String(currentStep + 1);
}

Update latency-test-utils.ts to add an ws-location-input hidden input element to the test HTML and update the automatic button to include data-auto-location and a selected class:

latency-test-utils.ts
export function initializeHTML(): void {
  document.body.innerHTML = `
    <input id="ws-location-input" value="automatic" />
    ${WSAutomaticView()}
    ${WSLocationsView()}
    ${VPNView()}
  `;
}

function WSAutomaticView(): string {
  return `
    <button data-value="automatic" data-auto-location data-action="select" class="selected">

Move the test files authentication_test.exs, oauth_test.exs, and otp_test.exs from test/deskterm_web/live/ to test/deskterm_web/live/authentication/.

Update authentication_test.exs to remove the Code.require_file call, add aliases for Deskterm.Repo, Deskterm.Schema.Profile, and DesktermWeb.TestUserAtoms, and add a profile assertion at the end of the successful login test:

authentication_test.exs
  alias Deskterm.Repo
  alias Deskterm.Schema.Profile
  alias DesktermWeb.TestOAuthAtoms
  alias DesktermWeb.TestUserAtoms
  alias DesktermWeb.TestUtilities
authentication_test.exs
    assert info_message == @success
    assert redirect_path == @path
    assert Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())

Update oauth_test.exs to remove the Code.require_file call.

Update otp_test.exs to remove the Code.require_file call and the @code module attribute, add aliases for Deskterm.Repo, Deskterm.Schema.Profile, and DesktermWeb.TestUserAtoms, replace @code references with TestUserAtoms.otp_code(), and add a profile assertion at the end of the successful verification test:

otp_test.exs
  alias Deskterm.Repo
  alias Deskterm.Schema.Profile
  alias DesktermWeb.TestUserAtoms
  alias DesktermWeb.TestUtilities

  @path "/app"
otp_test.exs
    render_click(view, "verify_otp_code", %{code: TestUserAtoms.otp_code()})
otp_test.exs
    assert_push_event(view, "show-animation", %{})
    assert Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())

Production Code

Create the below database migrations in the priv/repo/migrations directory:

20260319162848_create_profiles.exs
defmodule Deskterm.Repo.Migrations.CreateProfiles do
  use Ecto.Migration

  def change do
    create table(:profiles) do
      add :user_id, :binary_id, null: false
      add :subscription_type, :string, null: false, default: "none"
      add :fly_app_name, :string
      add :fly_volume_id, :string

      timestamps()
    end

    create unique_index(:profiles, [:user_id])
  end
end
20260319172813_create_workstations.exs
defmodule Deskterm.Repo.Migrations.CreateWorkstations do
  use Ecto.Migration

  def change do
    create table(:workstations) do
      add :profile_id, references(:profiles, on_delete: :delete_all), null: false
      add :ws_name, :string, null: false
      add :fly_machine_id, :string, null: false

      timestamps()
    end

    create unique_index(:workstations, [:profile_id, :ws_name])
    create index(:workstations, [:profile_id])
  end
end
20260320182841_add_fly_volume_region_to_profiles.exs
defmodule Deskterm.Repo.Migrations.AddFlyVolumeRegionToProfiles do
  use Ecto.Migration

  def change do
    alter table(:profiles) do
      add :fly_volume_region, :string
    end
  end
end
20260321120000_add_launching_at_to_profiles.exs
defmodule Deskterm.Repo.Migrations.AddLaunchingAtToProfiles do
  use Ecto.Migration

  def change do
    alter table(:profiles) do
      add :launching_at, :utc_datetime
    end
  end
end
20260321140000_add_unique_index_on_fly_machine_id_to_workstations.exs
defmodule Deskterm.Repo.Migrations.AddUniqueIndexOnFlyMachineIdToWorkstations do
  use Ecto.Migration

  def change do
    create unique_index(:workstations, [:profile_id, :fly_machine_id])
  end
end
20260407120000_move_fly_machine_id_to_profiles.exs
defmodule Deskterm.Repo.Migrations.MoveFlyMachineIdToProfiles do
  use Ecto.Migration

  def change do
    alter table(:profiles) do
      add :fly_machine_id, :string
    end

    flush()

    execute(
      "UPDATE profiles SET fly_machine_id = w.fly_machine_id FROM workstations w WHERE w.profile_id = profiles.id",
      "UPDATE workstations SET fly_machine_id = p.fly_machine_id FROM profiles p WHERE workstations.profile_id = p.id"
    )

    drop unique_index(:workstations, [:profile_id, :fly_machine_id])

    alter table(:workstations) do
      remove :fly_machine_id, :string
    end
  end
end
20260409120000_create_sessions.exs
defmodule Deskterm.Repo.Migrations.CreateSessions do
  use Ecto.Migration

  def change do
    create table(:sessions) do
      add :user_type, :string, null: false
      add :fly_app_name, :string, null: false
      add :ws_name, :string, null: false
      add :ip_address, :string
      add :started_at, :utc_datetime, null: false
      add :ended_at, :utc_datetime

      timestamps()
    end

    create index(:sessions, [:ended_at])
  end
end
20260409130000_add_profile_and_machine_fields_to_sessions.exs
defmodule Deskterm.Repo.Migrations.AddProfileAndMachineFieldsToSessions do
  use Ecto.Migration

  def change do
    alter table(:sessions) do
      add :profile_id, references(:profiles, on_delete: :nothing)
      add :fly_machine_id, :string
      add :fly_volume_id, :string
    end
  end
end
20260422143640_enable_rls.exs
defmodule Deskterm.Repo.Migrations.EnableRls do
  use Ecto.Migration

  def up do
    execute "ALTER TABLE profiles ENABLE ROW LEVEL SECURITY"
    execute "ALTER TABLE workstations ENABLE ROW LEVEL SECURITY"
    execute "ALTER TABLE sessions ENABLE ROW LEVEL SECURITY"
  end

  def down do
    execute "ALTER TABLE profiles DISABLE ROW LEVEL SECURITY"
    execute "ALTER TABLE workstations DISABLE ROW LEVEL SECURITY"
    execute "ALTER TABLE sessions DISABLE ROW LEVEL SECURITY"
  end
end

In the lib/deskterm/schemas directory, create a new file named profile.ex and populate it with the below content:

profile.ex
defmodule Deskterm.Schema.Profile do
  use Ecto.Schema
  import Ecto.Changeset

  alias Deskterm.Schema.Workstation

  schema "profiles" do
    field :user_id, :binary_id

    field :subscription_type, Ecto.Enum,
      values: [:none, :basic, :pro, :elite],
      default: :none

    field :fly_app_name, :string
    field :fly_machine_id, :string
    field :fly_volume_id, :string
    field :fly_volume_region, :string
    field :launching_at, :utc_datetime

    has_many :workstations, Workstation

    timestamps()
  end

  def changeset(profile, attributes) do
    profile
    |> cast(
      attributes,
      [
        :user_id,
        :subscription_type,
        :fly_app_name,
        :fly_machine_id,
        :fly_volume_id,
        :fly_volume_region,
        :launching_at
      ]
    )
    |> validate_required([:user_id])
    |> unique_constraint(:user_id)
  end
end

In the lib/deskterm/schemas directory, create a new file named workstation.ex and populate it with the below content:

workstation.ex
defmodule Deskterm.Schema.Workstation do
  use Ecto.Schema
  import Ecto.Changeset

  alias Deskterm.Schema.Profile

  schema "workstations" do
    belongs_to :profile, Profile
    field :ws_name, :string

    timestamps()
  end

  def changeset(workstation, attributes) do
    workstation
    |> cast(attributes, [:profile_id, :ws_name])
    |> validate_required([:profile_id, :ws_name])
    |> foreign_key_constraint(:profile_id)
    |> unique_constraint([:profile_id, :ws_name])
  end
end

In the lib/deskterm/schemas directory, create a new file named session.ex and populate it with the below content:

session.ex
defmodule Deskterm.Schema.Session do
  use Ecto.Schema
  import Ecto.Changeset

  schema "sessions" do
    field :user_type, :string
    field :fly_app_name, :string
    field :ws_name, :string
    field :ip_address, :string
    field :fly_machine_id, :string
    field :fly_volume_id, :string
    field :started_at, :utc_datetime
    field :ended_at, :utc_datetime

    belongs_to :profile, Deskterm.Schema.Profile

    timestamps()
  end

  def changeset(session, attributes) do
    session
    |> cast(attributes, [
      :user_type,
      :fly_app_name,
      :ws_name,
      :ip_address,
      :profile_id,
      :fly_machine_id,
      :fly_volume_id,
      :started_at,
      :ended_at
    ])
    |> validate_required([
      :user_type,
      :fly_app_name,
      :ws_name,
      :ip_address,
      :started_at
    ])
    |> validate_inclusion(:user_type, ~w(free_user new_user old_user))
  end
end

In the lib/deskterm directory, create a new file named profile.ex and populate it with the below content:

profile.ex
defmodule Deskterm.Profile do
  import Ecto.Query

  alias Deskterm.Repo
  alias Deskterm.Schema.Profile

  @lock_expiry_minutes 5

  def ensure_profile(user_id) do
    %Profile{}
    |> Profile.changeset(%{user_id: user_id})
    |> Repo.insert(
      on_conflict: [set: [user_id: user_id]],
      conflict_target: :user_id,
      returning: true
    )
  end

  def ensure_app_name(%Profile{fly_app_name: nil} = profile, app_name) do
    case profile
         |> Profile.changeset(%{fly_app_name: app_name})
         |> Repo.update() do
      {:ok, profile} -> {:ok, profile}
      _ -> :error
    end
  end

  def ensure_app_name(profile, _app_name), do: {:ok, profile}

  def ensure_volume(%Profile{fly_volume_id: nil} = profile, volume) do
    case profile
         |> Profile.changeset(%{
           fly_volume_id: volume.id,
           fly_volume_region: volume.region
         })
         |> Repo.update() do
      {:ok, profile} -> {:ok, profile}
      _ -> :error
    end
  end

  def ensure_volume(profile, _volume), do: {:ok, profile}

  def ensure_machine_id(%Profile{fly_machine_id: nil} = profile, machine_id) do
    case profile
         |> Profile.changeset(%{fly_machine_id: machine_id})
         |> Repo.update() do
      {:ok, profile} -> {:ok, profile}
      _ -> :error
    end
  end

  def ensure_machine_id(profile, _machine_id), do: {:ok, profile}

  def lock(_, :free_user), do: :ok

  def lock(profile, _) do
    now = DateTime.utc_now()
    expiry_time = DateTime.add(now, -@lock_expiry_minutes, :minute)

    {count, _} =
      from(profile_record in Profile,
        where: profile_record.id == ^profile.id,
        where:
          is_nil(profile_record.launching_at) or
            profile_record.launching_at < ^expiry_time
      )
      |> Repo.update_all(set: [launching_at: now])

    case count do
      1 -> :ok
      0 -> :error
    end
  end

  def unlock(_, :free_user), do: :ok

  def unlock(profile, _) do
    profile
    |> Profile.changeset(%{launching_at: nil})
    |> Repo.update()
  end
end

In the lib/deskterm directory, create a new file named workstation.ex and populate it with the below content:

workstation.ex
defmodule Deskterm.Workstation do
  import Ecto.Query

  alias Deskterm.Repo
  alias Deskterm.Schema.Workstation

  def ensure_workstation(profile, workstation) do
    %Workstation{}
    |> Workstation.changeset(%{
      profile_id: profile.id,
      ws_name: workstation.name
    })
    |> Repo.insert(
      on_conflict: :nothing,
      conflict_target: [:profile_id, :ws_name]
    )
    |> case do
      {:ok, _} -> :ok
      _ -> :error
    end
  end

  def has_workstation?(profile, ws_name) do
    Repo.exists?(
      from workstation in Workstation,
        where:
          workstation.profile_id == ^profile.id and
            workstation.ws_name == ^ws_name
    )
  end
end

In the lib/deskterm directory, create a new file named session.ex and populate it with the below content:

session.ex
defmodule Deskterm.Session do
  import Ecto.Query

  alias Deskterm.Repo
  alias Deskterm.Schema.Session

  def create(attributes) do
    %Session{}
    |> Session.changeset(attributes)
    |> Repo.insert()
  end

  def end_session(%Session{} = session) do
    session
    |> Session.changeset(%{ended_at: DateTime.utc_now()})
    |> Repo.update()
  end

  def update_session(%Session{} = session, attributes) do
    session
    |> Session.changeset(attributes)
    |> Repo.update()
  end

  def get_active_free_sessions_count do
    from(session in Session,
      where: session.user_type == "free_user" and is_nil(session.ended_at)
    )
    |> Repo.aggregate(:count)
  end

  def get_orphaned_sessions(oldest_allowed_start) do
    from(session in Session,
      where:
        is_nil(session.ended_at) and session.started_at < ^oldest_allowed_start
    )
    |> Repo.all()
  end
end

In the lib/deskterm directory, create a new file named session_cleanup.ex and populate it with the below content:

session_cleanup.ex
defmodule Deskterm.SessionCleanup do
  use Task, restart: :temporary
  require Logger

  alias Deskterm.Session

  def start_link(_arg) do
    Task.start_link(__MODULE__, :run, [])
  end

  def run do
    orphaned_sessions = Session.get_orphaned_sessions(DateTime.utc_now())

    Logger.info("There are #{length(orphaned_sessions)} orphaned sessions")

    Enum.each(orphaned_sessions, &clean_up_session/1)
  rescue
    _ ->
      Logger.critical("Session cleanup failed")
  end

  defp clean_up_session(session) do
    if session.user_type == "free_user" do
      case DesktermWeb.FlyAPI.delete_app(session.fly_app_name) do
        :ok ->
          Logger.info("Deleted orphaned Fly.io app: #{session.fly_app_name}")
          Session.end_session(session)

        :error ->
          Logger.warning(
            "Failed to delete orphaned Fly.io app: #{session.fly_app_name}"
          )
      end
    else
      Session.end_session(session)
    end
  rescue
    _ ->
      Logger.error("Failed to clean up orphaned session #{session.id}")
  end
end

Update application.ex to start the SessionCleanup task in the supervision tree:

application.ex
children = [
  DesktermWeb.Telemetry,
  Deskterm.Repo,
  Deskterm.SessionCleanup,
  {DNSCluster,
   query: Application.get_env(:deskterm, :dns_cluster_query) || :ignore},
  {Phoenix.PubSub, name: Deskterm.PubSub},
  DesktermWeb.Endpoint,
  Deskterm.Supabase.Client
]

In the lib/deskterm_web/api/fly directory, create a new file named fly_api.ex and populate it with the below content:

fly_api.ex
defmodule DesktermWeb.FlyAPI do
  alias DesktermWeb.Structs.Workstation.Command
  alias DesktermWeb.Structs.Workstation.Volume

  @callback create_app(name :: String.t()) :: :ok | :error
  @callback allocate_ip_address(
              app_name :: String.t(),
              type :: String.t()
            ) :: :ok | :error
  @callback create_volume(
              app_name :: String.t(),
              volume :: Volume.t()
            ) :: {:ok, String.t()} | :error
  @callback create_machine(
              app_name :: String.t(),
              workstation :: Workstation.t()
            ) :: {:ok, String.t()} | :error
  @callback start_machine(
              app_name :: String.t(),
              machine_id :: String.t()
            ) :: :ok | :error
  @callback wait_for_machine(
              app_name :: String.t(),
              machine_id :: String.t()
            ) :: :ok | :error
  @callback execute_command_async(
              app_name :: String.t(),
              command :: Command.t()
            ) :: {:ok, Task.t()} | :error
  @callback execute_command_sync(
              app_name :: String.t(),
              command :: Command.t()
            ) :: :ok | :error
  @callback stop_machine(
              app_name :: String.t(),
              machine_id :: String.t()
            ) :: :ok | :error
  @callback delete_app(app_name :: String.t()) :: :ok | :error

  def create_app(name), do: impl().create_app(name)
  def allocate_ip_address(app_name, type), do: impl().allocate_ip_address(app_name, type)
  def create_volume(app_name, volume), do: impl().create_volume(app_name, volume)
  def create_machine(app_name, workstation), do: impl().create_machine(app_name, workstation)
  def start_machine(app_name, machine_id), do: impl().start_machine(app_name, machine_id)
  def wait_for_machine(app_name, machine_id), do: impl().wait_for_machine(app_name, machine_id)
  def execute_command_async(app_name, command), do: impl().execute_command_async(app_name, command)
  def execute_command_sync(app_name, command), do: impl().execute_command_sync(app_name, command)
  def stop_machine(app_name, machine_id), do: impl().stop_machine(app_name, machine_id)
  def delete_app(app_name), do: impl().delete_app(app_name)

  def impl,
    do: Application.get_env(:deskterm, :fly_api, DesktermWeb.Fly)
end

In the lib/deskterm_web/api/fly directory, create a new file named fly.ex and populate it with the below content. This is the production implementation of the Fly.io API behaviour:

fly.ex
defmodule DesktermWeb.Fly do
  require Logger

  alias DesktermWeb.FlyStream
  alias DesktermWeb.FlyUtilities, as: Utilities
  alias DesktermWeb.Structs.Workstation.StreamDetails
  alias DesktermWeb.VirtualTerminal

  @organization "personal"

  @behaviour DesktermWeb.FlyAPI

  @impl true
  def create_app(name) do
    case Req.post(
           Utilities.client(),
           url: "/apps",
           json: %{app_name: name, org_slug: @organization}
         ) do
      {:ok, %Req.Response{status: 201}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def allocate_ip_address(app_name, type) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/ip_assignments",
           json: %{type: type}
         ) do
      {:ok, %Req.Response{status: 200}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def create_volume(app_name, volume) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/volumes",
           json: Utilities.get_create_volume_json(app_name, volume)
         ) do
      {:ok, %Req.Response{status: 201, body: %{"id" => volume_id}}} ->
        {:ok, volume_id}
      _ -> :error
    end
  end

  @impl true
  def create_machine(app_name, workstation) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/machines",
           json: Utilities.get_create_machine_json(workstation)
         ) do
      {:ok, %Req.Response{status: 200, body: %{"id" => machine_id}}} ->
        {:ok, machine_id}
      _ -> :error
    end
  end

  @impl true
  def start_machine(app_name, machine_id) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/machines/#{machine_id}/start"
         ) do
      {:ok, %Req.Response{status: 200}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def wait_for_machine(app_name, machine_id) do
    case Req.get(
           Utilities.client(),
           url: "/apps/#{app_name}/machines/#{machine_id}/wait",
           receive_timeout: 300_000,
           params: [state: "started"]
         ) do
      {:ok, %Req.Response{status: 200, body: %{"ok" => true}}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def execute_command_async(app_name, command) do
    fly = System.find_executable("fly")

    if fly do
      args = Utilities.get_command_arguments(app_name, command)

      task =
        Task.async(fn ->
          port =
            Port.open({:spawn_executable, fly}, [
              :binary,
              :exit_status,
              :stderr_to_stdout,
              {:args, args},
              {:env, [{~c"FLY_CONFIG_DIR", ~c"/tmp/.fly"}]}
            ])

          stream_details = %StreamDetails{
            caller: command.caller,
            port: port,
            buffer: VirtualTerminal.new()
          }

          FlyStream.stream_command_output(stream_details)
        end)

      {:ok, task}
    else
      Logger.critical("Fly executable not found")
      :error
    end
  end

  @impl true
  def execute_command_sync(app_name, command) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/machines/#{command.machine_id}/exec",
           json: %{cmd: command.command},
           receive_timeout: 300_000
         ) do
      {:ok, %Req.Response{status: 200}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def stop_machine(app_name, machine_id) do
    case Req.post(
           Utilities.client(),
           url: "/apps/#{app_name}/machines/#{machine_id}/stop",
           json: %{signal: "SIGTERM", timeout: "60s"}
         ) do
      {:ok, %Req.Response{status: 200}} -> :ok
      _ -> :error
    end
  end

  @impl true
  def delete_app(app_name) do
    case Req.delete(
           Utilities.client(),
           url: "/apps/#{app_name}"
         ) do
      {:ok, %Req.Response{status: status}} when status in [200, 202, 404] -> :ok
      _ -> :error
    end
  end
end

In the lib/deskterm_web/api/fly directory, create a new file named fly_utilities.ex and populate it with the below content:

fly_utilities.ex
defmodule DesktermWeb.FlyUtilities do
  @base_url "https://api.machines.dev/v1"

  def client do
    token = Application.fetch_env!(:deskterm, :fly_api_token)

    Req.new(
      base_url: @base_url,
      headers: [{"authorization", "Bearer #{token}"}]
    )
  end

  def get_create_volume_json(app_name, volume) do
    %{
      app_name: app_name,
      name: "#{volume.region}_#{volume.size_gb}gb",
      region: volume.region,
      size_gb: volume.size_gb
    }
  end

  def get_create_machine_json(workstation) do
    config = get_machine_config(workstation)

    %{
      config: config,
      name: workstation.name,
      region: workstation.region
    }
  end

  def get_command_arguments(app_name, command) do
    [
      "ssh",
      "console",
      "--app",
      app_name,
      "--machine",
      command.machine_id,
      "-C",
      command.command |> Enum.map_join(" ", &shell_escape/1),
      "-q"
    ]
  end

  defp get_machine_config(workstation) do
    %{
      env: %{
        "DOCKER_TLS_CERTDIR" => "",
        "DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS" => "",
        "DOCKER_OPTS" => "--userns-remap=default"
      },
      guest: %{
        memory_mb: workstation.machine.ram_mb
      },
      image: "docker:dind",
      services: [
        %{
          autostart: false,
          autostop: "stop",
          force_https: true,
          internal_port: 80,
          min_machines_running: 0,
          ports: [
            %{port: 443, handlers: ["http", "tls"]}
          ],
          protocol: "tcp"
        }
      ],
      auto_stop: %{
        signal: "SIGTERM",
        time_ms: 3_600_000
      },
      size: workstation.machine.size
    }
    |> attach_volume(workstation.machine.volume)
  end

  defp attach_volume(config, nil), do: config

  defp attach_volume(config, volume) do
    Map.put(config, :mounts, [
      %{volume: volume.id, path: "/var/lib/docker"}
    ])
  end

  defp shell_escape(argument) do
    if String.match?(argument, ~r/^[a-zA-Z0-9_.\/:-]+$/) do
      argument
    else
      "'" <> String.replace(argument, "'", "'\\''") <> "'"
    end
  end
end

In the lib/deskterm_web/api/fly directory, create a new file named fly_stream.ex and populate it with the below content:

fly_stream.ex
defmodule DesktermWeb.FlyStream do
  require Logger

  alias DesktermWeb.VirtualTerminal

  @drain_timeout_ms 1

  def stream_command_output(stream_details) do
    port = stream_details.port

    receive do
      {^port, {:data, data}} ->
        {new_buffer, exit_status} =
          drain_port(
            port,
            VirtualTerminal.write(stream_details.buffer, data)
          )

        send(
          stream_details.caller,
          {:stream_update, VirtualTerminal.get_lines(new_buffer)}
        )

        handle_exit_status(exit_status, %{stream_details | buffer: new_buffer})

      {^port, {:exit_status, 0}} ->
        handle_success(stream_details)

      {^port, {:exit_status, exit_code}} ->
        handle_error(exit_code, stream_details)
    end
  end

  defp drain_port(port, buffer) do
    receive do
      {^port, {:data, data}} ->
        drain_port(port, VirtualTerminal.write(buffer, data))

      {^port, {:exit_status, code}} ->
        {buffer, code}
    after
      @drain_timeout_ms -> {buffer, nil}
    end
  end

  defp handle_exit_status(exit_status, stream_details) do
    case exit_status do
      nil ->
        stream_command_output(stream_details)

      0 ->
        send(stream_details.caller, {:stream_complete, :ok})
        :ok

      code ->
        Logger.error("Command failed with exit code: #{code}")
        send(stream_details.caller, {:stream_complete, :error})
        :error
    end
  end

  defp handle_error(exit_code, stream_details) do
    Logger.error("Command failed with exit code: #{exit_code}")

    send(
      stream_details.caller,
      {:stream_update, VirtualTerminal.get_lines(stream_details.buffer)}
    )

    send(stream_details.caller, {:stream_complete, :error})
    :error
  end

  defp handle_success(stream_details) do
    send(
      stream_details.caller,
      {:stream_update, VirtualTerminal.get_lines(stream_details.buffer)}
    )

    send(stream_details.caller, {:stream_complete, :ok})
    :ok
  end
end

In the lib/deskterm_web/api/fly directory, create a new file named virtual_terminal.ex and populate it with the below content:

virtual_terminal.ex
defmodule DesktermWeb.VirtualTerminal do
  alias DesktermWeb.TerminalUtilities, as: Utilities

  @type t :: %__MODULE__{
          lines: [String.t()],
          row: non_neg_integer(),
          col: non_neg_integer()
        }

  defstruct lines: [""], row: 0, col: 0

  def new, do: %__MODULE__{}

  def write(%__MODULE__{} = buf, <<>>), do: buf

  def write(%__MODULE__{} = buf, <<"\e[", rest::binary>>) do
    case Utilities.parse_csi(rest) do
      {:ok, params, cmd, remainder} ->
        write(Utilities.handle_csi(buf, params, cmd), remainder)

      :incomplete ->
        buf
    end
  end

  def write(%__MODULE__{} = buf, <<"\e", _::binary-size(1), rest::binary>>) do
    write(buf, rest)
  end

  def write(%__MODULE__{} = buf, <<"\e">>), do: buf

  def write(%__MODULE__{} = buf, <<"\r", rest::binary>>) do
    write(%{buf | col: 0}, rest)
  end

  def write(%__MODULE__{} = buf, <<"\n", rest::binary>>) do
    write(%{buf | row: buf.row + 1, col: 0}, rest)
  end

  def write(%__MODULE__{} = buf, <<8, rest::binary>>) do
    write(%{buf | col: max(buf.col - 1, 0)}, rest)
  end

  def write(%__MODULE__{} = buf, <<9, rest::binary>>) do
    write(%{buf | col: (div(buf.col, 8) + 1) * 8}, rest)
  end

  def write(%__MODULE__{} = buf, <<c, rest::binary>>) when c < 0x20 do
    write(buf, rest)
  end

  def write(%__MODULE__{} = buf, data) do
    text_len = Utilities.printable_run_length(data, 0)
    <<text::binary-size(text_len), rest::binary>> = data
    write(Utilities.write_text(buf, text), rest)
  end

  def get_lines(%__MODULE__{lines: lines}) do
    lines
    |> Enum.reverse()
    |> Enum.drop_while(&(&1 == ""))
    |> Enum.reverse()
    |> then(fn
      [] -> [""]
      trimmed -> trimmed
    end)
  end
end

In the lib/deskterm_web/api/fly directory, create a new file named terminal_utilities.ex and populate it with the below content:

terminal_utilities.ex
defmodule DesktermWeb.TerminalUtilities do
  alias DesktermWeb.VirtualTerminal

  def handle_csi(buf, "?" <> _, _cmd), do: buf

  def handle_csi(buf, params, ?A) do
    n = param_int(params, 1)
    %{buf | row: max(buf.row - n, 0)}
  end

  def handle_csi(buf, params, ?B) do
    n = param_int(params, 1)
    %{buf | row: buf.row + n}
  end

  def handle_csi(buf, params, ?C) do
    n = param_int(params, 1)
    %{buf | col: buf.col + n}
  end

  def handle_csi(buf, params, ?D) do
    n = param_int(params, 1)
    %{buf | col: max(buf.col - n, 0)}
  end

  def handle_csi(buf, params, cmd) when cmd in [?H, ?f] do
    {r, c} = parse_cursor_pos(params)
    %{buf | row: r, col: c}
  end

  def handle_csi(buf, params, ?K) do
    case param_int(params, 0) do
      0 -> update_line(buf, &String.slice(&1, 0, buf.col))
      1 ->
        update_line(
          buf,
          &(String.duplicate(" ", buf.col) <> String.slice(&1, buf.col..-1//1))
        )
      2 -> update_line(buf, fn _ -> "" end)
      _ -> buf
    end
  end

  def handle_csi(buf, params, ?J) do
    case param_int(params, 0) do
      2 -> %{buf | lines: [""], row: 0, col: 0}
      _ -> buf
    end
  end

  def handle_csi(buf, _params, _cmd), do: buf

  def write_text(buf, text) do
    buf = update_line(buf, &overwrite_at(&1, buf.col, text))
    %{buf | col: buf.col + String.length(text)}
  end

  defp update_line(%VirtualTerminal{} = buf, fun) do
    lines = ensure_row(buf.lines, buf.row)
    current = Enum.at(lines, buf.row)
    %{buf | lines: List.replace_at(lines, buf.row, fun.(current))}
  end

  defp overwrite_at(line, col, text) do
    text_len = String.length(text)
    padded = String.pad_trailing(line, col + text_len)

    String.slice(padded, 0, col) <>
      text <> String.slice(padded, (col + text_len)..-1//1)
  end

  def printable_run_length(<<>>, n), do: n
  def printable_run_length(<<c, _::binary>>, n) when c < 0x20, do: n
  def printable_run_length(<<_c, rest::binary>>, n),
    do: printable_run_length(rest, n + 1)

  defp ensure_row(lines, row) do
    count = length(lines)

    if row < count,
      do: lines,
      else: lines ++ List.duplicate("", row - count + 1)
  end

  def parse_csi(data), do: parse_csi_acc(data, <<>>)

  defp parse_csi_acc(<<>>, _acc), do: :incomplete

  defp parse_csi_acc(<<c, rest::binary>>, acc)
       when c in ?0..?9 or c == ?; or c == ?? do
    parse_csi_acc(rest, <<acc::binary, c>>)
  end

  defp parse_csi_acc(<<c, rest::binary>>, acc)
       when c in ?A..?Z or c in ?a..?z do
    {:ok, acc, c, rest}
  end

  defp parse_csi_acc(<<_c, rest::binary>>, _acc) do
    {:ok, "", ??, rest}
  end

  defp param_int("", default), do: default

  defp param_int(params, default) do
    case hd(String.split(params, ";")) do
      "" -> default
      s -> String.to_integer(s)
    end
  end

  defp parse_cursor_pos(params) do
    parts = String.split(params, ";")
    {csi_coord(Enum.at(parts, 0)), csi_coord(Enum.at(parts, 1))}
  end

  defp csi_coord(nil), do: 0
  defp csi_coord(""), do: 0
  defp csi_coord(s), do: max(String.to_integer(s) - 1, 0)
end

In the lib/deskterm_web/api/turnstile directory, create a new file named turnstile_api.ex and populate it with the below content:

turnstile_api.ex
defmodule DesktermWeb.TurnstileAPI do
  @callback verify(token :: String.t()) :: :ok | :error

  def verify(token) do
    impl().verify(token)
  end

  def site_key do
    Application.get_env(:deskterm, :turnstile)[:site_key]
  end

  defp impl,
    do:
      Application.get_env(
        :deskterm,
        :turnstile_api,
        DesktermWeb.Turnstile
      )
end

In the lib/deskterm_web/api/turnstile directory, create a new file named turnstile.ex and populate it with the below content:

turnstile.ex
defmodule DesktermWeb.Turnstile do
  require Logger

  @behaviour DesktermWeb.TurnstileAPI

  @verify_url "https://challenges.cloudflare.com/turnstile/v0/siteverify"

  @impl true
  def verify(token) when is_binary(token) and token != "" do
    secret_key = Application.get_env(:deskterm, :turnstile)[:secret_key]

    case Req.post(@verify_url,
           form: [secret: secret_key, response: token]
         ) do
      {:ok, %Req.Response{status: 200, body: %{"success" => true}}} ->
        :ok

      {:ok,
       %Req.Response{
         status: 200,
         body: %{"success" => false, "error-codes" => error_codes}
       }} ->
        Logger.warning("Turnstile verification failed: #{inspect(error_codes)}")
        :error

      _ ->
        Logger.error("Failed to verify Turnstile token")
        :error
    end
  end

  def verify(_), do: :error
end

In the lib/deskterm_web/structs directory, create a new file named workstation.ex and populate it with the below content:

workstation.ex
defmodule DesktermWeb.Structs.Workstation.Volume do
  defstruct id: nil, region: nil, size_gb: nil
end

defmodule DesktermWeb.Structs.Workstation do
  defstruct machine: nil, name: nil, region: nil
end

defmodule DesktermWeb.Structs.Workstation.Machine do
  defstruct id: nil, ram_mb: nil, size: nil, volume: nil
end

defmodule DesktermWeb.Structs.Workstation.Command do
  defstruct caller: nil, command: nil, machine_id: nil
end

defmodule DesktermWeb.Structs.Workstation.StreamDetails do
  defstruct buffer: nil, caller: nil, port: nil
end

defmodule DesktermWeb.Structs.Workstation.APICallDetails do
  defstruct json: nil, method: nil, path: nil
end

In the lib/deskterm_web/live/workstation directory, create a new file named launch_sequence.ex and populate it with the below content:

launch_sequence.ex
defmodule DesktermWeb.Workstation.LaunchSequence do
  alias DesktermWeb.FlyApp

  @free_user [
    :initialize_session,
    :create_app,
    :allocate_ipv4_address,
    :allocate_ipv6_address,
    :create_volume,
    :create_machine,
    :wait_for_machine,
    :wait_for_docker,
    :configure_firewall,
    :pull_image,
    :start_workstation_container,
    :write_proxy_configuration,
    :start_proxy_container,
    :update_session,
    :start_session_countdown
  ]

  @new_user [
    :initialize_session,
    :create_app,
    :allocate_ipv4_address,
    :allocate_ipv6_address,
    :create_volume,
    :create_machine,
    :wait_for_machine,
    :wait_for_docker,
    :configure_firewall,
    :pull_image,
    :start_workstation_container,
    :write_proxy_configuration,
    :start_proxy_container,
    :update_session
  ]

  @old_user_old_workstation [
    :initialize_session,
    :start_machine,
    :wait_for_machine,
    :wait_for_docker,
    :configure_firewall,
    :resume_workstation_container,
    :write_proxy_configuration,
    :start_proxy_container,
    :update_session
  ]

  @old_user_new_workstation [
    :initialize_session,
    :start_machine,
    :wait_for_machine,
    :wait_for_docker,
    :configure_firewall,
    :pull_image,
    :start_workstation_container,
    :write_proxy_configuration,
    :start_proxy_container,
    :update_session
  ]

  def get(profile, ws_name) do
    case FlyApp.get_user_type(profile) do
      :old_user ->
        if Deskterm.Workstation.has_workstation?(profile, ws_name),
          do: @old_user_old_workstation,
          else: @old_user_new_workstation

      user_type ->
        launch_sequence(user_type)
    end
  end

  defp launch_sequence(:free_user), do: @free_user
  defp launch_sequence(:new_user), do: @new_user
end

In the lib/deskterm_web/live/workstation directory, create a new file named images.ex and populate it with the below content:

images.ex
defmodule DesktermWeb.Workstation.Images do
  @images %{
    "Chrome" => "lscr.io/linuxserver/chrome",
    "Chromium" => "lscr.io/linuxserver/chromium",
    "Edge" => "lscr.io/linuxserver/msedge",
    "Firefox" => "lscr.io/linuxserver/firefox",
    "LibreWolf" => "lscr.io/linuxserver/librewolf",
    "Mullvad" => "lscr.io/linuxserver/mullvad-browser",
    "Opera" => "lscr.io/linuxserver/opera",
    "Vivaldi" => "lscr.io/linuxserver/vivaldi",
    "Altus" => "lscr.io/linuxserver/altus",
    "Audacity" => "lscr.io/linuxserver/audacity",
    "Discord" => "lscr.io/linuxserver/webcord",
    "FileZilla" => "lscr.io/linuxserver/filezilla",
    "GIMP" => "lscr.io/linuxserver/gimp",
    "IDEA" => "lscr.io/linuxserver/intellij-idea",
    "KeePassXC" => "lscr.io/linuxserver/keepassxc",
    "Obsidian" => "lscr.io/linuxserver/obsidian",
    "Pidgin" => "lscr.io/linuxserver/pidgin",
    "PyCharm" => "lscr.io/linuxserver/pycharm",
    "Signal" => "lscr.io/linuxserver/signal",
    "Steam" => "lscr.io/linuxserver/steam",
    "Telegram" => "lscr.io/linuxserver/telegram",
    "Thunderbird" => "lscr.io/linuxserver/thunderbird",
    "VS Code" => "lscr.io/linuxserver/openvscode-server",
    "WeChat" => "lscr.io/linuxserver/weixin"
  }

  @process_names %{
    "Chrome" => "chrome",
    "Chromium" => "chromium",
    "Edge" => "msedge",
    "Firefox" => "firefox",
    "LibreWolf" => "librewolf",
    "Mullvad" => "mullvad-browser",
    "Opera" => "opera",
    "Vivaldi" => "vivaldi",
    "Altus" => "altus",
    "Audacity" => "audacity",
    "Discord" => "webcord",
    "FileZilla" => "filezilla",
    "GIMP" => "gimp",
    "IDEA" => "idea",
    "KeePassXC" => "keepassxc",
    "Obsidian" => "obsidian",
    "Pidgin" => "pidgin",
    "PyCharm" => "pycharm",
    "Signal" => "signal-desktop",
    "Steam" => "steam",
    "Telegram" => "telegram-desktop",
    "Thunderbird" => "thunderbird",
    "VS Code" => "openvscode-server",
    "WeChat" => "weixin"
  }

  def get_image(ws_name), do: Map.fetch!(@images, ws_name)
  def get_process_name(ws_name), do: Map.get(@process_names, ws_name)
end

Move the file workstations.ex from lib/deskterm_web/live/utilities/ to lib/deskterm_web/live/workstation/.

In the lib/deskterm_web/live/workstation directory, create a new file named workstation.ex and populate it with the below content:

workstation.ex
defmodule DesktermWeb.Workstation do
  require Logger

  alias DesktermWeb.FlyApp
  alias DesktermWeb.Structs.Workstation.APICallDetails
  alias DesktermWeb.UILog
  alias DesktermWeb.Workstation.Utilities

  def launch(socket) do
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)
    launch_sequence = socket.assigns.launch_sequence

    app_name =
      socket.assigns.fly_app_name || FlyApp.generate_app_name(user_type)

    socket = Phoenix.Component.assign(socket, fly_app_name: app_name)

    with :ok <- Deskterm.Profile.lock(profile, user_type),
         [first_step | _] <- launch_sequence do
      send(self(), first_step)
      {:noreply, socket}
    else
      _ ->
        Logger.warning("Failed to lock profile")
        Utilities.abort_launch(socket, false)
    end
  end

  def create_volume(socket) do
    app_name = socket.assigns.fly_app_name
    region = socket.assigns.ws_location
    profile = socket.assigns.profile

    volume = get_volume(region, profile)

    api_call_details = %APICallDetails{
      method: "POST",
      path: "/v1/apps/#{app_name}/volumes",
      json: DesktermWeb.FlyUtilities.get_create_volume_json(app_name, volume)
    }

    socket = UILog.log_api_call_on_ui(socket, api_call_details)

    FlyApp.execute_api_call(socket, fn ->
      with {:ok, volume_id} <-
             DesktermWeb.FlyAPI.create_volume(app_name, volume),
           {:ok, profile} <- ensure_volume(profile, volume_id, region) do
        {:ok, %{profile: profile, fly_volume_id: volume_id}}
      end
    end)
  end

  def create_machine(socket) do
    app_name = socket.assigns.fly_app_name
    profile = socket.assigns.profile
    workstation = Utilities.get_workstation(socket)

    json = DesktermWeb.FlyUtilities.get_create_machine_json(workstation)

    socket =
      UILog.log_api_call_on_ui(socket, %APICallDetails{
        method: "POST",
        path: "/v1/apps/#{app_name}/machines",
        json: json
      })

    FlyApp.execute_api_call(socket, fn ->
      with {:ok, machine_id} <-
             DesktermWeb.FlyAPI.create_machine(app_name, workstation),
           {:ok, profile} <- Utilities.ensure_machine_id(profile, machine_id) do
        {:ok, %{fly_machine_id: machine_id, profile: profile}}
      end
    end)
  end

  def start_machine(socket) do
    app_name = socket.assigns.fly_app_name
    machine_id = socket.assigns.fly_machine_id

    socket =
      UILog.log_api_call_on_ui(socket, %APICallDetails{
        method: "POST",
        path: "/v1/apps/#{app_name}/machines/#{machine_id}/start"
      })

    FlyApp.execute_api_call(socket, fn ->
      DesktermWeb.FlyAPI.start_machine(app_name, machine_id)
    end)
  end

  def wait_for_machine(socket) do
    app_name = socket.assigns.fly_app_name
    machine_id = socket.assigns.fly_machine_id

    socket =
      UILog.log_api_call_on_ui(socket, %APICallDetails{
        method: "GET",
        path: "/v1/apps/#{app_name}/machines/#{machine_id}/wait?state=started"
      })

    FlyApp.execute_api_call(socket, fn ->
      DesktermWeb.FlyAPI.wait_for_machine(app_name, machine_id)
    end)
  end

  defp get_volume(region, profile) do
    subscription_type = (profile && profile.subscription_type) || :none

    %DesktermWeb.Structs.Workstation.Volume{
      region: region,
      size_gb: Utilities.get_volume_size(subscription_type)
    }
  end

  defp ensure_volume(profile, volume_id, region) do
    case FlyApp.get_user_type(profile) do
      :free_user ->
        {:ok, profile}

      _ ->
        Deskterm.Profile.ensure_volume(profile, %{id: volume_id, region: region})
    end
  end
end

In the lib/deskterm_web/live/workstation directory, create a new file named workstation_utilities.ex and populate it with the below content:

workstation_utilities.ex
defmodule DesktermWeb.Workstation.Utilities do
  import Phoenix.Component
  import Phoenix.LiveView

  alias Deskterm.Schema.Profile
  alias DesktermWeb.FlyApp

  def advance_step(socket) do
    next_step = socket.assigns.launch_ws_step + 1
    launch_sequence = socket.assigns.launch_sequence
    next_task = Enum.at(launch_sequence, next_step)
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)

    case next_task do
      nil -> Deskterm.Profile.unlock(profile, user_type)
      next_message -> send(self(), next_message)
    end

    {:noreply,
     assign(socket,
       launch_ws_step: next_step,
       launch_ws_current_task: next_task
     )}
  end

  def abort_launch(socket, unlock_profile \\ true) do
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)

    if unlock_profile do
      Deskterm.Profile.unlock(profile, user_type)
    end

    kill_active_task(socket)

    socket =
      socket
      |> assign(
        view: "overview",
        active_task: nil,
        console_command: nil
      )
      |> put_flash(:error, "Failed to launch workstation")

    {:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
  end

  def kill_active_task(socket) do
    case socket.assigns[:active_task] do
      %Task{} = task -> Task.shutdown(task, :brutal_kill)
      _ -> :ok
    end
  end

  def ensure_workstation(profile, workstation) do
    case FlyApp.get_user_type(profile) do
      :free_user -> :ok
      _ -> Deskterm.Workstation.ensure_workstation(profile, workstation)
    end
  end

  def ensure_machine_id(profile, machine_id) do
    case FlyApp.get_user_type(profile) do
      :free_user -> {:ok, profile}
      _ -> Deskterm.Profile.ensure_machine_id(profile, machine_id)
    end
  end

  def get_machine_size(:none), do: "performance-2x"
  def get_machine_size(:basic), do: "performance-2x"
  def get_machine_size(:pro), do: "performance-4x"
  def get_machine_size(:elite), do: "performance-8x"

  def get_ram_size(:none), do: 4096
  def get_ram_size(:basic), do: 4096
  def get_ram_size(:pro), do: 8192
  def get_ram_size(:elite), do: 16_384

  def get_volume_size(:none), do: 40
  def get_volume_size(:basic), do: 40
  def get_volume_size(:pro), do: 80
  def get_volume_size(:elite), do: 160

  def get_workstation(socket) do
    profile = socket.assigns.profile
    region = socket.assigns.ws_location
    ws_name = socket.assigns.ws_name
    volume_id = socket.assigns[:fly_volume_id]

    %DesktermWeb.Structs.Workstation{
      machine: get_machine(profile, volume_id),
      name: ws_name,
      region: region
    }
  end

  defp get_machine(profile, volume_id) do
    subscription_type = (profile && profile.subscription_type) || :none
    volume = get_volume(profile) || get_volume(volume_id)

    %DesktermWeb.Structs.Workstation.Machine{
      ram_mb: get_ram_size(subscription_type),
      size: get_machine_size(subscription_type),
      volume: volume
    }
  end

  defp get_volume(%Profile{fly_volume_id: id}) when not is_nil(id) do
    %DesktermWeb.Structs.Workstation.Volume{id: id}
  end

  defp get_volume(%Profile{}), do: nil
  defp get_volume(nil), do: nil

  defp get_volume(id) when is_binary(id),
    do: %DesktermWeb.Structs.Workstation.Volume{id: id}
end

In the lib/deskterm_web/live directory, create the below files.

Create a new file named launch_verification.ex and populate it with the below content:

launch_verification.ex
defmodule DesktermWeb.LaunchVerification do
  import Phoenix.LiveView
  import Phoenix.Component

  alias DesktermWeb.FlyApp
  alias DesktermWeb.TurnstileAPI
  alias DesktermWeb.Workstation.LaunchSequence

  @turnstile_error_message "Bot verification failed"
  @capacity_error_message "Out of capacity"
  @max_free_concurrent_ws 1

  def verify(socket, turnstile_token) do
    with :ok <- verify_turnstile(turnstile_token),
         :ok <- verify_capacity(socket) do
      start_launch(socket)
    else
      {:error, message} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, message)
          |> push_event("show-toast", %{})

        {:noreply, socket}
    end
  end

  defp verify_turnstile(turnstile_token) do
    case TurnstileAPI.verify(turnstile_token) do
      :ok -> :ok
      :error -> {:error, @turnstile_error_message}
    end
  end

  defp verify_capacity(socket) do
    profile = socket.assigns.profile

    if FlyApp.get_user_type(profile) == :free_user and
         Deskterm.Session.get_active_free_sessions_count() >=
           @max_free_concurrent_ws do
      {:error, @capacity_error_message}
    else
      :ok
    end
  end

  defp start_launch(socket) do
    profile = socket.assigns.profile
    launch_sequence = LaunchSequence.get(profile, socket.assigns.ws_name)

    socket =
      assign(socket,
        view: "loading_workstation",
        launch_sequence: launch_sequence,
        launch_ws_step: 0,
        launch_ws_current_task: hd(launch_sequence),
        loading: nil
      )

    send(self(), :start_workstation)
    {:noreply, socket}
  end
end

Create a new file named censorship.ex and populate it with the below content:

censorship.ex
defmodule DesktermWeb.Censorship do
  @secret_fragment_size 6
  @redacted "******"

  def censor_secrets(socket, text) when is_binary(text) do
    [censored] = censor_secrets(socket, [text])
    censored
  end

  def censor_secrets(socket, lines) when is_list(lines) do
    supabase_configuration =
      Application.get_env(:deskterm, Deskterm.Supabase.Client, [])

    endpoint_configuration =
      Application.get_env(:deskterm, DesktermWeb.Endpoint, [])

    repo_configuration = Application.get_env(:deskterm, Deskterm.Repo, [])

    secrets =
      [
        Application.get_env(:deskterm, :fly_api_token) || "",
        socket.assigns[:ws_auth_token] || "",
        supabase_configuration[:api_key] || "",
        endpoint_configuration[:secret_key_base] || "",
        repo_configuration[:url] || ""
      ]
      |> Enum.filter(&(&1 != ""))

    fragments = build_fragments(secrets)

    Enum.map(lines, fn line ->
      Enum.reduce(fragments, line, fn fragment, acc ->
        String.replace(acc, fragment, @redacted)
      end)
    end)
  end

  defp build_fragments(secrets) do
    secrets
    |> Enum.filter(&(byte_size(&1) >= @secret_fragment_size))
    |> Enum.flat_map(fn secret ->
      fragment_count = div(byte_size(secret), @secret_fragment_size)

      for i <- 0..(fragment_count - 1) do
        binary_part(secret, i * @secret_fragment_size, @secret_fragment_size)
      end
    end)
    |> Enum.uniq()
  end
end

Create a new file named ui_log.ex and populate it with the below content:

ui_log.ex
defmodule DesktermWeb.UILog do
  import Phoenix.Component
  import Phoenix.LiveView, only: [push_event: 3]

  alias DesktermWeb.Censorship
  alias DesktermWeb.Structs.Workstation.Command

  def log_api_call_on_ui(socket, details) do
    log_content =
      case details.json do
        nil ->
          "#{details.method} #{details.path}"

        json ->
          "#{details.method} #{details.path}\n#{Jason.encode!(json, pretty: true)}"
      end

    push_event(socket, "log-update", %{log: log_content})
  end

  def log_command_on_ui(socket, %Command{} = command) do
    command =
      ("$ " <> Enum.join(command.command, " "))
      |> then(&Censorship.censor_secrets(socket, &1))

    socket
    |> assign(console_command: command)
    |> push_event("log-update", %{log: command})
  end

  def log_command_on_ui(socket, command) when is_binary(command) do
    command = Censorship.censor_secrets(socket, command)

    socket
    |> assign(console_command: command)
    |> push_event("log-update", %{log: command})
  end
end

Create a new file named ip_address.ex and populate it with the below content:

ip_address.ex
defmodule DesktermWeb.IPAddress do
  use Phoenix.LiveView

  def get_client_ip_address(socket) do
    get_proxied_ip_address(socket) || get_direct_ip_address(socket)
  end

  defp get_proxied_ip_address(socket) do
    with headers when is_list(headers) <- get_connect_info(socket, :x_headers),
         {_, value} <- List.keyfind(headers, "x-forwarded-for", 0) do
      value |> String.split(",") |> List.first() |> String.trim()
    else
      _ -> nil
    end
  end

  defp get_direct_ip_address(socket) do
    case get_connect_info(socket, :peer_data) do
      %{address: address} ->
        address |> :inet.ntoa() |> to_string() |> normalize_ip_address()

      _ ->
        nil
    end
  end

  defp normalize_ip_address("::ffff:" <> ipv4), do: ipv4
  defp normalize_ip_address(ip), do: ip
end

Create a new file named fly_app.ex and populate it with the below content:

fly_app.ex
defmodule DesktermWeb.FlyApp do
  import Phoenix.Component

  alias Deskterm.Schema.Profile
  alias DesktermWeb.Structs.Workstation.APICallDetails
  alias DesktermWeb.UILog

  def create_app(socket) do
    profile = socket.assigns.profile
    app_name = socket.assigns.fly_app_name

    api_call_details = %APICallDetails{
      method: "POST",
      path: "/v1/apps",
      json: %{app_name: app_name, org_slug: "personal"}
    }

    socket = UILog.log_api_call_on_ui(socket, api_call_details)

    execute_api_call(socket, fn ->
      with :ok <- DesktermWeb.FlyAPI.create_app(app_name),
           {:ok, profile} <- ensure_app_name(profile, app_name) do
        {:ok, %{fly_app_name: app_name, profile: profile}}
      end
    end)
  end

  def allocate_ipv4_address(socket) do
    app_name = socket.assigns.fly_app_name

    api_call_details = %APICallDetails{
      method: "POST",
      path: "/v1/apps/#{app_name}/ip_assignments",
      json: %{type: "shared_v4"}
    }

    socket = UILog.log_api_call_on_ui(socket, api_call_details)

    execute_api_call(socket, fn ->
      DesktermWeb.FlyAPI.allocate_ip_address(app_name, "shared_v4")
    end)
  end

  def allocate_ipv6_address(socket) do
    app_name = socket.assigns.fly_app_name

    api_call_details = %APICallDetails{
      method: "POST",
      path: "/v1/apps/#{app_name}/ip_assignments",
      json: %{type: "v6"}
    }

    socket = UILog.log_api_call_on_ui(socket, api_call_details)

    execute_api_call(socket, fn ->
      DesktermWeb.FlyAPI.allocate_ip_address(app_name, "v6")
    end)
  end

  def get_user_type(profile) do
    case profile do
      nil -> :free_user
      %Profile{subscription_type: :none} -> :free_user
      %Profile{fly_app_name: nil} -> :new_user
      %Profile{fly_app_name: _} -> :old_user
    end
  end

  def execute_api_call(socket, api_call) do
    caller = self()

    task =
      Task.async(fn ->
        case api_call.() do
          {:ok, assigns} -> send(caller, {:api_call_complete, :ok, assigns})
          :ok -> send(caller, {:api_call_complete, :ok, %{}})
          _ -> send(caller, {:api_call_complete, :error})
        end
      end)

    {:noreply, assign(socket, active_task: task)}
  end

  def generate_app_name(user_type) do
    prefix = get_app_name_prefix(user_type)

    uuid =
      :crypto.strong_rand_bytes(16)
      |> Base.encode16(case: :lower)

    "#{prefix}-#{uuid}"
  end

  defp get_app_name_prefix(:free_user), do: "free"
  defp get_app_name_prefix(:new_user), do: "user"

  defp ensure_app_name(profile, app_name) do
    case get_user_type(profile) do
      :free_user -> {:ok, profile}
      _ -> Deskterm.Profile.ensure_app_name(profile, app_name)
    end
  end
end

Create a new file named session.ex in the lib/deskterm_web/live directory and populate it with the below content:

session.ex
defmodule DesktermWeb.Session do
  use Phoenix.LiveView
  require Logger

  alias DesktermWeb.FlyApp
  alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities

  @session_length_minutes 5

  def initialize(socket) do
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)

    attributes = %{
      user_type: Atom.to_string(user_type),
      fly_app_name: socket.assigns.fly_app_name,
      ws_name: socket.assigns.ws_name,
      ip_address: socket.assigns.client_ip_address,
      profile_id: profile && profile.id,
      started_at: DateTime.utc_now()
    }

    case Deskterm.Session.create(attributes) do
      {:ok, session} ->
        socket = assign(socket, ws_session: session)
        WorkstationUtilities.advance_step(socket)

      _ ->
        Logger.critical("Failed to initialize session")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def update(socket) do
    session = socket.assigns.ws_session

    attributes = %{
      fly_machine_id: socket.assigns.fly_machine_id,
      fly_volume_id: socket.assigns.fly_volume_id
    }

    case Deskterm.Session.update_session(session, attributes) do
      {:ok, session} ->
        socket = assign(socket, ws_session: session)
        WorkstationUtilities.advance_step(socket)

      _ ->
        Logger.critical("Failed to update session")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def start_countdown(socket) do
    timer_reference =
      Process.send_after(
        self(),
        :session_expiration,
        :timer.minutes(@session_length_minutes)
      )

    session_expiration =
      DateTime.utc_now()
      |> DateTime.add(@session_length_minutes, :minute)

    socket =
      assign(socket,
        session_timer_reference: timer_reference,
        session_expiration: session_expiration
      )

    WorkstationUtilities.advance_step(socket)
  end

  def on_expiration(socket) do
    app_name = socket.assigns.fly_app_name
    session = socket.assigns.ws_session

    Task.start(fn -> end_session(app_name, session) end)

    socket =
      socket
      |> assign(
        view: "overview",
        overview_view: "browsers",
        active_task: nil,
        console_command: nil,
        ws_session: nil,
        session_timer_reference: nil,
        session_expiration: nil
      )
      |> put_flash(:info, "Free session expired")

    {:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
  end

  def end_session(app_name, session) do
    cond do
      app_name && session.user_type == "free_user" ->
        delete_fly_app(app_name, session)

      app_name && session.fly_machine_id ->
        stop_fly_machine(app_name, session)

      true ->
        Deskterm.Session.end_session(session)
    end
  end

  defp delete_fly_app(app_name, session) do
    case DesktermWeb.FlyAPI.delete_app(app_name) do
      :ok -> Deskterm.Session.end_session(session)
      :error -> Logger.error("Failed to delete Fly.io app: #{app_name}")
    end
  end

  defp stop_fly_machine(app_name, session) do
    stop_containers(app_name, session)

    case DesktermWeb.FlyAPI.stop_machine(app_name, session.fly_machine_id) do
      :ok -> Deskterm.Session.end_session(session)
      :error -> Logger.error("Failed to stop Fly.io machine: #{session.fly_machine_id}")
    end
  end

  defp stop_containers(app_name, session) do
    stop_containers_command =
      DesktermWeb.Container.Utilities.get_stop_containers_cmd(session.ws_name)

    command = %DesktermWeb.Structs.Workstation.Command{
      machine_id: session.fly_machine_id,
      command: "sh -c '#{stop_containers_command}'"
    }

    DesktermWeb.FlyAPI.execute_command_sync(app_name, command)
  end
end

In the lib/deskterm_web/live/container directory, create a new file named container.ex and populate it with the below content:

container.ex
defmodule DesktermWeb.Container do
  import Phoenix.Component

  require Logger

  alias DesktermWeb.Container.Firewall
  alias DesktermWeb.Container.Utilities
  alias DesktermWeb.Structs.Workstation.Command
  alias DesktermWeb.UILog
  alias DesktermWeb.Workstation.Images
  alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities

  @wait_for_docker_script """
  elapsed=0
  timeout=120
  until docker info > /dev/null 2>&1; do
    sleep 5
    elapsed=$((elapsed+5))
    if [ $elapsed -ge $timeout ]; then
      echo 'Timed out waiting for Docker'
      exit 1
    fi
  done
  """

  def wait_for_docker(socket) do
    app_name = socket.assigns.fly_app_name
    machine_id = socket.assigns.fly_machine_id

    command = %Command{
      caller: self(),
      machine_id: machine_id,
      command: ["sh", "-c", @wait_for_docker_script]
    }

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to wait for Docker")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def configure_firewall(socket) do
    app_name = socket.assigns.fly_app_name
    command = Firewall.get_firewall_command(socket)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to configure firewall")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def pull_image(socket) do
    app_name = socket.assigns.fly_app_name
    ws_name = socket.assigns.ws_name
    image = Images.get_image(ws_name)
    command = Utilities.get_docker_pull_command(socket, image)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to pull image")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def start_workstation_container(socket) do
    app_name = socket.assigns.fly_app_name
    profile = socket.assigns.profile
    workstation = WorkstationUtilities.get_workstation(socket)
    command = Utilities.get_run_ws_command(socket)

    with :ok <- WorkstationUtilities.ensure_workstation(profile, workstation),
         {:ok, task} <-
           DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      socket = UILog.log_command_on_ui(socket, command)
      {:noreply, assign(socket, active_task: task)}
    else
      _ ->
        Logger.error("Failed to start workstation container")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def resume_workstation_container(socket) do
    app_name = socket.assigns.fly_app_name
    command = Utilities.get_resume_ws_command(socket)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to resume workstation container")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def write_proxy_configuration(socket) do
    app_name = socket.assigns.fly_app_name
    command = Utilities.get_proxy_config_command(socket)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket =
          UILog.log_command_on_ui(
            socket,
            "$ echo '<auth_proxy_config>' | base64 -d > /etc/proxy/nginx.conf"
          )

        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to write proxy configuration")
        WorkstationUtilities.abort_launch(socket)
    end
  end

  def start_proxy_container(socket) do
    app_name = socket.assigns.fly_app_name

    authentication_token =
      :crypto.strong_rand_bytes(64)
      |> Base.url_encode64(padding: false)

    command = Utilities.get_start_proxy_command(socket, authentication_token)
    socket = assign(socket, ws_auth_token: authentication_token)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to start proxy container")
        WorkstationUtilities.abort_launch(socket)
    end
  end
end

In the lib/deskterm_web/live/container directory, create a new file named container_utilities.ex and populate it with the below content:

container_utilities.ex
defmodule DesktermWeb.Container.Utilities do
  require Logger

  alias DesktermWeb.Structs.Workstation.Command
  alias DesktermWeb.Workstation.Images

  def get_docker_pull_command(socket, image) do
    machine_id = socket.assigns.fly_machine_id

    pull_cmd = "docker pull #{image}"

    script =
      "apk add -q --no-cache util-linux-misc 2>/dev/null; " <>
        "script -qfc \"#{pull_cmd}\" /dev/null 2>&1 || #{pull_cmd}"

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: ["sh", "-c", script]
    }
  end

  def get_run_ws_command(socket) do
    machine_id = socket.assigns.fly_machine_id
    ws_name = socket.assigns.ws_name
    image = Images.get_image(ws_name)

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: get_ws_docker_command(image, ws_name)
    }
  end

  def get_resume_ws_command(socket) do
    machine_id = socket.assigns.fly_machine_id
    ws_name = socket.assigns.ws_name

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: [
        "sh",
        "-c",
        get_stop_containers_cmd() <> "docker start #{ws_name}"
      ]
    }
  end

  def get_proxy_config_command(socket) do
    machine_id = socket.assigns.fly_machine_id
    auth_proxy_config_base64 = auth_proxy_config() |> Base.encode64()

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: [
        "sh",
        "-c",
        "mkdir -p /etc/proxy && rm -rf /etc/proxy/nginx.conf && echo '#{auth_proxy_config_base64}' | base64 -d > /etc/proxy/nginx.conf"
      ]
    }
  end

  def get_start_proxy_command(socket, authentication_token) do
    machine_id = socket.assigns.fly_machine_id

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: [
        "sh",
        "-c",
        "docker rm -f authentication-proxy 2>/dev/null; " <>
          "docker run -d " <>
          "-e AUTHENTICATION_TOKEN=#{authentication_token} " <>
          "--name authentication-proxy " <>
          "--network host " <>
          "--restart unless-stopped " <>
          "-v /etc/proxy/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro " <>
          "openresty/openresty:alpine"
      ]
    }
  end

  def get_stop_containers_cmd(ws_name \\ nil) do
    graceful_stop =
      case Images.get_process_name(ws_name) do
        nil ->
          ""

        process_name ->
          "docker exec #{ws_name} pkill -INT #{process_name} 2>/dev/null; sleep 5; "
      end

    graceful_stop <> "docker ps -q | xargs -r docker stop -t 10 2>/dev/null; "
  end

  defp get_ws_docker_command(image, ws_name) do
    [
      "sh",
      "-c",
      get_stop_containers_cmd() <>
        "docker run -d " <>
        "--cap-drop ALL " <>
        "--cap-add CHOWN " <>
        "--cap-add DAC_OVERRIDE " <>
        "--cap-add FOWNER " <>
        "--cap-add FSETID " <>
        "--cap-add KILL " <>
        "--cap-add NET_BIND_SERVICE " <>
        "--cap-add NET_RAW " <>
        "--cap-add SETGID " <>
        "--cap-add SETPCAP " <>
        "--cap-add SETUID " <>
        "--cap-add SYS_CHROOT " <>
        "-e PGID=1000 " <>
        "-e PUID=1000 " <>
        "-e TZ=Etc/UTC " <>
        "--name #{ws_name} " <>
        "-p 127.0.0.1:3000:3000 " <>
        "--restart unless-stopped " <>
        "--shm-size 1gb " <>
        image
    ]
  end

  @proxy_configuration_path Path.join(__DIR__, "authentication_proxy.conf")
  @external_resource @proxy_configuration_path
  @proxy_configuration File.read!(@proxy_configuration_path)

  defp auth_proxy_config, do: @proxy_configuration
end

In the lib/deskterm_web/live/container directory, create a new file named authentication_proxy.conf and populate it with the below content:

authentication_proxy.conf
env AUTHENTICATION_TOKEN;

events {
    worker_connections 1024;
}

http {
    lua_shared_dict authenticated_ips 1m;

    server {
        listen 80;

        location = /authenticate {
            default_type text/html;

            content_by_lua_block {
                if ngx.req.get_method() ~= "POST" then
                    ngx.status = 405
                    ngx.say("Method not allowed")
                    return
                end

                ngx.req.read_body()
                local args, err = ngx.req.get_post_args()
                if not args or not args.token then
                    ngx.status = 400
                    ngx.say("Missing token")
                    return
                end

                local expected = os.getenv("AUTHENTICATION_TOKEN")
                if args.token ~= expected then
                    ngx.status = 403
                    ngx.say("Invalid token")
                    return
                end

                local client_ip = ngx.var.remote_addr
                ngx.shared.authenticated_ips:set(client_ip, true, 576000)

                ngx.redirect("/")
            }
        }

        location / {
            access_by_lua_block {
                local client_ip = ngx.var.remote_addr
                if not ngx.shared.authenticated_ips:get(client_ip) then
                    ngx.status = 401
                    ngx.say("Unauthorized")
                    return ngx.exit(401)
                end
            }

            proxy_pass http://127.0.0.1:3000;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 576000s;
            proxy_send_timeout 576000s;
        }
    }
}

In the lib/deskterm_web/live/container directory, create a new file named firewall.ex and populate it with the below content:

firewall.ex
defmodule DesktermWeb.Container.Firewall do
  alias DesktermWeb.FlyApp
  alias DesktermWeb.Structs.Workstation.Command

  def get_firewall_command(socket) do
    machine_id = socket.assigns.fly_machine_id
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)

    firewall_rules = get_firewall_rules(user_type)

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: ["sh", "-c", firewall_rules]
    }
  end

  defp get_firewall_rules(user_type) do
    block_internet_access =
      "iptables -C DOCKER-USER -i docker0 -j DROP 2>/dev/null || " <>
        "iptables -A DOCKER-USER -i docker0 -j DROP; " <>
        "iptables -C DOCKER-USER -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || " <>
        "iptables -I DOCKER-USER -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT; "

    block_host_access =
      "iptables -C INPUT -i docker0 -j DROP 2>/dev/null || " <>
        "iptables -A INPUT -i docker0 -j DROP; " <>
        "iptables -C INPUT -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || " <>
        "iptables -I INPUT -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT"

    if user_type == :free_user do
      block_internet_access <> block_host_access
    else
      block_host_access
    end
  end
end

Update endpoint.ex to include :peer_data and :x_headers in the WebSocket connect info, and to increase the WebSocket timeout:

endpoint.ex
socket "/live", Phoenix.LiveView.Socket,
  websocket: [
    connect_info: [:peer_data, :x_headers, session: @session_options],
    timeout: 900_000
  ],
  longpoll: [
    connect_info: [:peer_data, :x_headers, session: @session_options]
  ]

Update app_live.ex to add the new assigns in mount/3, handle the start_workstation event, add handle_info callbacks for each launch step, and add a terminate/2 callback:

app_live.ex
@impl true
def mount(_params, session, socket) do
  client_ip_address = IPAddress.get_client_ip_address(socket)

  {:ok,
   assign(socket,
     active_task: nil,
     apps: Workstations.get_apps(),
     browsers: Workstations.get_browsers(),
     client_ip_address: client_ip_address,
     console_command: nil,
     email: nil,
     fly_app_name: nil,
     fly_machine_id: nil,
     fly_volume_id: nil,
     is_verifying: false,
     launch_sequence: [],
     launch_ws_step: 0,
     launch_ws_current_task: "",
     loading: nil,
     modal: Authentication.get_modal(),
     otp_code: %{},
     overview_view: "browsers",
     profile: nil,
     session: nil,
     session_data: session,
     session_expiration: nil,
     session_timer_reference: nil,
     should_show_modal: false,
     view: "login",
     ws_auth_token: nil,
     ws_location: nil,
     ws_name: nil,
     ws_session: nil
   )}
end

Add the start_workstation event handler:

app_live.ex
@impl true
def handle_event(
      "start_workstation",
      %{"ws_location" => ws_location, "turnstile_token" => turnstile_token},
      socket
    ) do
  socket =
    socket
    |> assign(loading: "start_workstation", ws_location: ws_location)
    |> Phoenix.LiveView.clear_flash()

  send(self(), {:verify_launch, turnstile_token})
  {:noreply, socket}
end

Add handle_info callbacks for each step in the launch sequence:

app_live.ex
@impl true
def handle_info({:verify_launch, turnstile_token}, socket) do
  LaunchVerification.verify(socket, turnstile_token)
end

@impl true
def handle_info(:start_workstation, socket) do
  Workstation.launch(socket)
end

@impl true
def handle_info(:create_app, socket), do: FlyApp.create_app(socket)

@impl true
def handle_info(:allocate_ipv4_address, socket), do: FlyApp.allocate_ipv4_address(socket)

@impl true
def handle_info(:allocate_ipv6_address, socket), do: FlyApp.allocate_ipv6_address(socket)

@impl true
def handle_info(:create_volume, socket), do: Workstation.create_volume(socket)

@impl true
def handle_info(:create_machine, socket), do: Workstation.create_machine(socket)

@impl true
def handle_info(:start_machine, socket), do: Workstation.start_machine(socket)

@impl true
def handle_info(:wait_for_machine, socket), do: Workstation.wait_for_machine(socket)

@impl true
def handle_info(:wait_for_docker, socket), do: Container.wait_for_docker(socket)

@impl true
def handle_info(:configure_firewall, socket), do: Container.configure_firewall(socket)

@impl true
def handle_info(:pull_image, socket), do: Container.pull_image(socket)

@impl true
def handle_info(:start_workstation_container, socket), do: Container.start_workstation_container(socket)

@impl true
def handle_info(:resume_workstation_container, socket), do: Container.resume_workstation_container(socket)

@impl true
def handle_info(:write_proxy_configuration, socket), do: Container.write_proxy_configuration(socket)

@impl true
def handle_info(:start_proxy_container, socket), do: Container.start_proxy_container(socket)

@impl true
def handle_info(:initialize_session, socket), do: Session.initialize(socket)

@impl true
def handle_info(:update_session, socket), do: Session.update(socket)

@impl true
def handle_info(:start_session_countdown, socket), do: Session.start_countdown(socket)

@impl true
def handle_info(:session_expiration, socket), do: Session.on_expiration(socket)

Add handlers for stream updates, API call completion, and process monitoring:

app_live.ex
@impl true
def handle_info({:stream_update, lines}, socket) do
  if socket.assigns.active_task do
    lines = Censorship.censor_secrets(socket, lines)
    header = socket.assigns[:console_command]
    lines = [header, "" | lines]

    {:noreply,
     push_event(socket, "log-update", %{log: Enum.join(lines, "\n")})}
  else
    {:noreply, socket}
  end
end

@impl true
def handle_info({:api_call_complete, :ok, assigns}, socket) do
  socket = socket |> assign(active_task: nil) |> assign(assigns)
  WorkstationUtilities.advance_step(socket)
end

@impl true
def handle_info({:api_call_complete, :error}, socket) do
  socket = assign(socket, active_task: nil)
  WorkstationUtilities.abort_launch(socket)
end

@impl true
def handle_info({:stream_complete, :ok}, socket) do
  socket = assign(socket, active_task: nil)
  WorkstationUtilities.advance_step(socket)
end

@impl true
def handle_info({:stream_complete, :error}, socket) do
  socket = assign(socket, active_task: nil)
  WorkstationUtilities.abort_launch(socket)
end

Add a terminate/2 callback to clean up resources when the LiveView process terminates:

app_live.ex
@impl true
def terminate(_reason, socket) do
  app_name = socket.assigns.fly_app_name
  ws_session = socket.assigns.ws_session
  session_timer_reference = socket.assigns.session_timer_reference

  WorkstationUtilities.kill_active_task(socket)

  if session_timer_reference do
    Process.cancel_timer(session_timer_reference)
  end

  if ws_session do
    Task.start(fn -> Session.end_session(app_name, ws_session) end)
  end

  :ok
end

Update app_live.html.heex to conditionally render the loading_workstation view when a workstation is being launched:

app_live.html.heex
<%= if @view == "loading_workstation" do %>
  <DesktermWeb.Views.loading_workstation
    fly_app_name={@fly_app_name}
    launch_ws_current_task={@launch_ws_current_task}
    launch_sequence={@launch_sequence}
    launch_ws_step={@launch_ws_step}
    session_expiration={@session_expiration}
    ws_auth_token={@ws_auth_token}
  />
<% end %>

Also pass the loading assign to the overview:

app_live.html.heex
<DesktermWeb.Views.overview
  apps={@apps}
  browsers={@browsers}
  loading={@loading}
  overview_view={@overview_view}
/>

Update overview.html.heex to pass the loading assign to the launch view:

overview.html.heex
<DesktermWeb.Views.launch loading={@loading} />

Update workstations.html.heex to use ws_name instead of workstation as the phx-value parameter:

workstations.html.heex
phx-value-ws_name={workstation}

Update views.ex to add launch_task_label/1 functions that map each launch step atom to a human-readable label:

views.ex
def launch_task_label(:initialize_session), do: "Initializing Session"
def launch_task_label(:create_app), do: "Creating Fly.io App"
def launch_task_label(:allocate_ipv4_address), do: "Allocating IPv4 Address"
def launch_task_label(:allocate_ipv6_address), do: "Allocating IPv6 Address"
def launch_task_label(:create_volume), do: "Creating Volume"
def launch_task_label(:create_machine), do: "Creating Machine"
def launch_task_label(:start_machine), do: "Starting Machine"
def launch_task_label(:wait_for_machine), do: "Waiting for Machine"
def launch_task_label(:wait_for_docker), do: "Waiting for Docker"
def launch_task_label(:configure_firewall), do: "Configuring Firewall"
def launch_task_label(:pull_image), do: "Pulling Docker Image"
def launch_task_label(:start_workstation_container), do: "Starting Workstation"
def launch_task_label(:resume_workstation_container), do: "Resuming Workstation"
def launch_task_label(:write_proxy_configuration), do: "Configuring Authentication Proxy"
def launch_task_label(:start_proxy_container), do: "Starting Authentication Proxy"
def launch_task_label(:update_session), do: "Updating Session"
def launch_task_label(:start_session_countdown), do: "Starting Session Countdown"
def launch_task_label(nil), do: "Connecting to Workstation"

Move the files authentication.ex, oauth.ex, and otp.ex from lib/deskterm_web/live/utilities/ to lib/deskterm_web/live/authentication/.

Update authentication.ex to replace the nested case statements in exchange_code_for_session/2 with a with block that also fetches the user's profile:

authentication.ex
  @dialyzer {:no_match, exchange_code_for_session: 2}
  def exchange_code_for_session(socket, pkce) do
    with {:ok, client} <- supabase_client().get_client(),
         {:ok, session} <-
           supabase_auth().exchange_code_for_session(
             client,
             pkce.code,
             pkce.code_verifier
           ),
         {:ok, profile} <-
           Deskterm.Profile.ensure_profile(session.user.id) do
      {:noreply,
       socket
       |> assign(
         session: session,
         profile: profile,
         fly_app_name: profile.fly_app_name,
         fly_machine_id: profile.fly_machine_id,
         view: "overview"
       )
       |> put_flash(:info, @success_message)
       |> push_event("show-animation", %{})
       |> push_patch(to: @path)}
    else
      {:error, _reason} ->
        Logger.error("Failed to exchange code for session")
        redirect_on_error(socket)
    end
  end

Update otp.ex to replace the case statement in supabase_verify_otp_code/2 with a with block that also fetches the user's profile and assigns it to the socket:

otp.ex
  defp supabase_verify_otp_code(socket, authentication_details) do
    with {:ok, session} <-
           Authentication.supabase_auth().verify_otp(
             authentication_details.client,
             authentication_details.details
           ),
         {:ok, profile} <-
           Deskterm.Profile.ensure_profile(session.user.id) do
      socket =
        socket
        |> assign(
          loading: nil,
          is_verifying: false,
          session: session,
          profile: profile,
          fly_app_name: profile.fly_app_name,
          fly_machine_id: profile.fly_machine_id,
          view: "overview"
        )
        |> put_flash(:info, @verification_success_msg)
        |> push_event("show-toast", %{})
        |> push_event("show-animation", %{})

      {:noreply, socket}
    else
      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @verification_error_msg)
          |> push_event("show-toast", %{})

        Logger.error("Failed to verify OTP code")
        {:noreply, socket}
    end
  end

Update [root.html.heex] to add the Cloudflare Turnstile script tag:

root.html.heex
<!-- Cloudflare Turnstile -->
<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
  async
  defer
>
</script>

Update login.html.heex to change min-h-screen to min-h-dvh on the outer container div:

login.html.heex
<div
  class="min-h-dvh flex flex-col justify-center px-6 py-12 lg:px-8"
  id="login"
  phx-hook="ClientError"
>

Update 404.html.heex and 500.html.heex to change min-h-screen to min-h-dvh.

When the initial pricing was drafted, I had still assumed that it is possible to use the shared CPU machines on Fly.io. However after testing workstations running with the shared CPUs, it was decided that the performance with those machines is too poor. Therefore performance machines will be used (with dedicated CPU cores). Naturally with this change the pricing must be adjusted. Update pricing.html.heex with the following changes:

Update the Basic tier price from €5 to €15, hours from 50 hours per month to 40 hours per month, and replace the No persistent storage, Browsers, Apps, Desktops, 15+ workstation locations, and 100+ VPN locations feature items with a single 40GB SSD item:

pricing.html.heex
<div class="text-4xl font-extrabold mt-2">€15</div>
pricing.html.heex
                40 hours per month
pricing.html.heex
                40GB SSD

Update the Pro tier price from €15 to €45, hours from 100 hours per month to 80 hours per month, and replace the same feature items with 80GB SSD:

pricing.html.heex
<div class="text-4xl font-extrabold mt-2">€45</div>
pricing.html.heex
                80 hours per month
pricing.html.heex
                80GB SSD

Rename the Pro Max tier to Elite, update price from €45 to €90, hours from 250 hours per month to 160 hours per month, and replace the same feature items with 160GB SSD:

pricing.html.heex
<!-- Elite -->
pricing.html.heex
        <h3 class="text-3xl font-bold tracking-wide">Elite</h3>
        <div class="text-4xl font-extrabold mt-2">€90</div>
pricing.html.heex
                160 hours per month
pricing.html.heex
                160GB SSD

In the lib/deskterm_web/views/templates directory, create a new file named loading_workstation.html.heex and populate it with the below content:

loading_workstation.html.heex
<div
  id="loading-workstation"
  class="flex flex-col items-center justify-center min-h-dvh p-6"
  phx-hook="LoadingProgress"
  data-step-index={@launch_ws_step}
  data-total-steps={length(@launch_sequence)}
>
  <!-- Spinning Logo -->
  <div class="mb-10">
    <img
      src="/images/logo.png"
      alt="Deskterm"
      class="size-25 animate-spin"
    />
  </div>

  <!-- Progress Text -->
  <p class="text-lg font-medium text-white mb-8">
    {DesktermWeb.Views.launch_task_label(@launch_ws_current_task)}
  </p>

  <!-- Progress Bar -->
  <div class="w-full max-w-sm">
    <div class="relative">
      <div class="
          h-2
          bg-gray-800
          rounded-full
          border
          border-white/10
          overflow-hidden">
        <div
          data-progress-bar
          class="h-full rounded-full bg-green-400"
          style="width: 0%"
        />
      </div>
    </div>
    <p data-progress-label class="text-xs text-gray-400 text-center mt-3">
      0%
    </p>
  </div>

  <!-- Estimated Launch Time -->
  <div class="mt-6 text-center">
    <p class="text-xs font-medium text-gray-400">Estimated Launch Time</p>
    <div class="
        inline-grid
        grid-cols-[auto_auto]
        gap-x-4
        gap-y-1
        text-xs
        text-gray-500">
      <span class="text-right">New workstation</span>
      <span class="text-left text-gray-400">1 – 3 minutes</span>
      <span class="text-right">Existing workstation</span>
      <span class="text-left text-gray-400">20 - 60 seconds</span>
    </div>
  </div>

  <!-- UI Log -->
  <div
    id="ui-log"
    phx-hook="UILog"
    class="
      w-full max-w-4xl mt-8
      bg-black/60
      backdrop-blur-sm
      border border-white/5
      rounded-xl
      overflow-hidden
      shadow-2xl
      shadow-green-400/5
    "
  >
    <div class="flex items-center px-4 py-2.5 bg-white/3 border-b border-white/5">
      <div class="flex items-center gap-1.5">
        <div class="size-1.5 rounded-full bg-green-400/60"></div>
        <span class="text-[10px] text-gray-500 font-mono tracking-widest uppercase">
          Log
        </span>
      </div>
    </div>
    <div
      id="log-container"
      phx-update="ignore"
      class="
        px-5 py-4
        h-96
        overflow-y-auto
        scrollbar-none
      "
    >
      <pre
        id="log-content"
        class="
          font-mono
          text-xs
          text-green-400
          leading-tight
          whitespace-pre-wrap
          break-all
          m-0"
      ></pre>
    </div>
  </div>

  <!-- Invisible Form for Authenticating with Authentication Proxy -->
  <%= if @launch_ws_current_task == nil and @ws_auth_token do %>
    <form
      id="ws-auth-form"
      method="POST"
      action={"https://#{@fly_app_name}.fly.dev/authenticate"}
      target="ws-iframe"
      phx-hook="ConnectToWS"
      class="hidden"
    >
      <input type="hidden" name="token" value={@ws_auth_token} />
    </form>
  <% end %>

  <!-- Session Countdown Timer -->
  <%= if @session_expiration do %>
    <div
      id="session-countdown"
      phx-hook="Countdown"
      data-expiration={DateTime.to_iso8601(@session_expiration)}
      class="
        fixed
        top-4
        left-1/2
        -translate-x-1/2
        z-60
        px-4
        py-2
        bg-gray-900/90
        backdrop-blur-sm
        border
        border-white/10
        rounded-lg
        shadow-lg"
    >
      <div class="flex items-center gap-2">
        <div class="size-2 rounded-full bg-green-400 animate-pulse"></div>
        <span class="text-sm font-mono text-white">
          <span data-countdown-label>05:00</span>
        </span>
      </div>
    </div>
  <% end %>
</div>

<iframe
  id="ws-iframe"
  name="ws-iframe"
  class="invisible pointer-events-none fixed inset-0 w-full h-full border-none z-50 bg-transparent"
  allowfullscreen
>
</iframe>

Update launch.html.heex with the following changes:

Add phx-update="ignore" to the region selector container and change the hidden input name from region to ws_location:

launch.html.heex
<div id="region-selector" phx-hook="WSLocation" phx-update="ignore">
  <label class="block text-xl font-medium text-gray-300 mb-2">
    Workstation Location
  </label>
  <input
    type="hidden"
    name="ws_location"
    id="ws-location-input"
    value="automatic"
    required

Add the data-auto-location attribute to the automatic location button:

launch.html.heex
            data-value="automatic"
            data-auto-location
            data-action="select"

Add phx-update="ignore" to the VPN location selector container:

launch.html.heex
<div id="vpn-location-selector" phx-hook="VPNLocation" phx-update="ignore">

Add a new Bot Verification section after the VPN Location section and before the Start Workstation button:

launch.html.heex
<!-- Bot Verification -->
    <div
      class="bg-gray-900/50 rounded-2xl p-4 border border-white/10"
      id="turnstile-hook"
      phx-hook="Turnstile"
      phx-update="ignore"
      data-turnstile-site-key={DesktermWeb.TurnstileAPI.site_key()}
    >
      <label class="block text-xl font-medium text-gray-300 mb-4">
        Bot Verification
      </label>
      <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
        <div data-turnstile-container class="flex justify-center"></div>
        <div class="flex justify-center">
          <span data-turnstile-status>
            <span
              data-turnstile-status-verifying
              class="flex flex-col items-center gap-2 text-gray-400"
            >
              <span class="[&>svg]:w-12 [&>svg]:h-12">
                <DesktermWeb.AppComponents.loading_spinner
                  color="white"
                  id="turnstile-verifying"
                />
              </span>
            </span>
            <span
              data-turnstile-status-success
              style="display: none"
              class="flex flex-col items-center gap-4 text-green-400"
            >
              <span class="text-5xl">🧑 ✓</span>
              <span class="text-sm">You seem to be human</span>
            </span>
            <span
              data-turnstile-status-error
              style="display: none"
              class="flex flex-col items-center gap-4 text-red-400"
            >
              <span class="text-5xl">🤖 ✗</span>
              <span class="text-sm">You seem to be a robot</span>
            </span>
          </span>
        </div>
      </div>
      <input
        type="hidden"
        name="turnstile_token"
        value=""
        data-turnstile-token
      />
    </div>

Update the Start Workstation button to add layout classes, a disabled attribute tied to @loading, and a loading spinner:

launch.html.heex
    <button
      type="submit"
      class="
        relative
        flex
        w-full
        justify-center
        items-center
        px-4
        py-1.5
        rounded-md
        bg-indigo-600
        text-lg
        font-semibold
        text-white
        shadow-xs
        hover:bg-indigo-500
        focus-visible:outline-2
        focus-visible:ring-2
        focus-visible:ring-indigo-400
        cursor-pointer"
      disabled={@loading != nil}
    >
      Start Workstation
      <span
        class="absolute right-4 flex items-center"
        style="width: 24px; height: 24px;"
      >
        <%= if @loading == "start_workstation" do %>
          <DesktermWeb.AppComponents.loading_spinner
            color="white"
            id={@loading}
          />
        <% end %>
      </span>
    </button>

In the assets/ts directory, create a new file named turnstile.ts and populate it with the below content:

turnstile.ts
declare global {
  interface Window {
    turnstile?: {
      render: (
        container: string | HTMLElement,
        options: Record<string, unknown>
      ) => string;
      reset: (widgetId: string) => void;
      remove: (widgetId: string) => void;
    };
  }
}

export function initializeTurnstile(hook: HTMLElement): Turnstile {
  return new Turnstile(hook);
}

enum TurnstileStatus {
  Verifying = "verifying",
  Error = "error",
  Success = "success"
}

export class Turnstile {
  private static readonly pollIntervalMS = 100;

  private widgetID: string | null = null;
  private readonly container: HTMLElement | null;
  private readonly tokenInput: HTMLInputElement | null;
  private readonly siteKey: string | undefined;
  private readonly statusElements: Record<TurnstileStatus, HTMLElement | null>;

  constructor(hook: HTMLElement) {
    this.container = hook.querySelector<HTMLElement>(
      "[data-turnstile-container]"
    );
    this.tokenInput = hook.querySelector<HTMLInputElement>(
      "[data-turnstile-token]"
    );
    this.siteKey = hook.dataset.turnstileSiteKey;
    this.statusElements = {
      [TurnstileStatus.Verifying]: hook.querySelector(
        "[data-turnstile-status-verifying]"
      ),
      [TurnstileStatus.Success]: hook.querySelector(
        "[data-turnstile-status-success]"
      ),
      [TurnstileStatus.Error]: hook.querySelector(
        "[data-turnstile-status-error]"
      ),
    };

    if (!this.container || !this.tokenInput || !this.siteKey) return;

    this.showStatus(TurnstileStatus.Verifying);
    this.renderWhenReady();
  }

  removeWidget() {
    if (this.widgetID && window.turnstile) {
      window.turnstile.remove(this.widgetID);
      this.widgetID = null;
    }
  }

  private showStatus(status: TurnstileStatus) {
    for (const key of Object.values(TurnstileStatus)) {
      const statusElement = this.statusElements[key];
      if (statusElement) {
        const isActive = key === status;
        statusElement.style.display = isActive ? "" : "none";
      }
    }
  }

  private renderWhenReady() {
    if (!window.turnstile) {
      setTimeout(
        () => this.renderWhenReady(),
        Turnstile.pollIntervalMS
      );
      return;
    }

    if (!this.container) return;

    this.widgetID = window.turnstile.render(
      this.container,
      this.getRenderOptions()
    );
  }

  private getRenderOptions(): Record<string, unknown> {
    return {
      sitekey: this.siteKey,
      theme: "dark",
      retry: "auto",
      "retry-interval": 5000,
      appearance: "always",
      callback: (token: string) => {
        if (this.tokenInput) this.tokenInput.value = token;
        this.showStatus(TurnstileStatus.Success);
      },
      "error-callback": () => {
        if (this.tokenInput) this.tokenInput.value = "";
        this.showStatus(TurnstileStatus.Error);
      },
      "timeout-callback": () => {
        if (this.tokenInput) this.tokenInput.value = "";
        this.showStatus(TurnstileStatus.Verifying);
        this.reset();
      },
    };
  }

  private reset() {
    if (this.widgetID && window.turnstile) {
      window.turnstile.reset(this.widgetID);
    }
  }
}

In the assets/ts directory, create a new file named ui-log.ts and populate it with the below content:

ui-log.ts
import type { ViewHookInterface } from "phoenix_live_view";

export function initializeUILog(hook: ViewHookInterface): void {
  const logContainer = hook.el.querySelector<HTMLElement>('#log-container');
  const logContent = hook.el.querySelector<HTMLElement>('#log-content');

  if (!logContainer || !logContent) return;

  hook.handleEvent("log-update", ({ log }: { log: string }) => {
    logContent.textContent = log;
    logContainer.scrollTop = logContainer.scrollHeight;
  });
}

In the assets/ts directory, create a new file named countdown.ts and populate it with the below content:

countdown.ts
const INTERVAL_MS = 1000;

interface Countdown {
  label: HTMLElement;
  expirationEpoch: number;
}

export function initializeCountdown(
  container: HTMLElement
): number | undefined {
  const label = container.querySelector<HTMLElement>("[data-countdown-label]");
  const expiration = container.dataset.expiration;

  if (!label || !expiration) return undefined;

  const expirationEpoch = new Date(expiration).getTime();
  const countdown: Countdown = { label, expirationEpoch };
  const intervalId = setInterval(
    () => updateCountdown(countdown, intervalId),
    INTERVAL_MS
  );
  return intervalId;
}

function updateCountdown(
  countdown: Countdown,
  intervalId: number
) {
  const remainingMS = countdown.expirationEpoch - Date.now();

  if (remainingMS <= 0) {
    countdown.label.textContent = "00:00";
    clearInterval(intervalId);

    return;
  }

  const remainingSeconds = Math.ceil(remainingMS / 1000);
  const minutes = String(Math.floor(remainingSeconds / 60)).padStart(2, "0");
  const seconds = String(remainingSeconds % 60).padStart(2, "0");

  countdown.label.textContent = `${minutes}:${seconds}`;
}

In the assets/ts directory, create a new file named loading-progress.ts and populate it with the below content:

loading-progress.ts
const EASE_FACTOR = 100;
const FRAME_INTERVAL_MS = 5;
const MIN_PROGRESS_UPDATE = 0.001;
const MAX_PROGRESS = 100;

interface ProgressState {
  stepIndex: number;
  totalSteps: number;
  sectionSize: number;
  displayProgress: number;
}

interface Progress {
  bar: HTMLElement;
  label: HTMLElement;
}

export function initializeLoadingProgress(container: HTMLElement): void {
  const bar = container.querySelector<HTMLElement>('[data-progress-bar]');
  const label = container.querySelector<HTMLElement>('[data-progress-label]');

  if (!bar || !label) return;

  const totalSteps = getTotalSteps(container);

  const state: ProgressState = {
    stepIndex: getStepIndex(container),
    totalSteps,
    sectionSize: 100 / totalSteps,
    displayProgress: 0,
  };

  const progress: Progress = { bar, label };

  animate(progress, state);
  observeDOMForUpdates(container, state);
}

function getStepIndex(container: HTMLElement): number {
  return parseInt(container.dataset.stepIndex as string);
}

function getTotalSteps(container: HTMLElement): number {
  return parseInt(container.dataset.totalSteps as string);
}

function animate(
  progress: Progress,
  state: ProgressState
): void {
  const sectionStart = state.stepIndex * state.sectionSize;
  const maxTarget = sectionStart + state.sectionSize;

  if (state.displayProgress < maxTarget) {
    const remaining = maxTarget - state.displayProgress;
    const delta = remaining / (state.sectionSize * EASE_FACTOR);
    state.displayProgress = Math.min(
      state.displayProgress + Math.max(delta, MIN_PROGRESS_UPDATE),
      maxTarget
    );
  }

  const progressPercentage = Math.min(state.displayProgress, MAX_PROGRESS);
  progress.bar.style.width = `${progressPercentage}%`;
  progress.label.textContent = `${Math.round(progressPercentage)}%`;

  window.setTimeout(() => animate(progress, state), FRAME_INTERVAL_MS);
}

function observeDOMForUpdates(
  container: HTMLElement,
  state: ProgressState
): void {
  const observer = new MutationObserver(() => {
    state.stepIndex = getStepIndex(container);
  });

  observer.observe(container, {
    attributes: true,
    attributeFilter: ['data-step-index'],
  });
}

In the assets/ts directory, create a new file named workstation-connection.ts and populate it with the below content:

workstation-connection.ts
export function connectToWorkstation(form: HTMLElement): void {
  const iFrame =
    document.getElementById("ws-iframe") as HTMLIFrameElement | null;

  if (!(form instanceof HTMLFormElement) || !iFrame) {
    return;
  }

  const visibilityHandler = (): void =>
    onVisibilityChange(iFrame, visibilityHandler);

  iFrame.addEventListener(
    "load",
    () => showIFrame(iFrame, visibilityHandler),
    { once: true }
  );

  document.addEventListener("visibilitychange", visibilityHandler);

  form.submit();
}

function showIFrame(iFrame: HTMLIFrameElement, visibilityHandler: () => void): void {
  iFrame.classList.remove("invisible", "pointer-events-none");
  iFrame.focus();
  document.removeEventListener("visibilitychange", visibilityHandler);
}

function onVisibilityChange(iFrame: HTMLIFrameElement, visibilityHandler: () => void): void {
  if (document.visibilityState === "visible" && iFrame.contentWindow) {
    try {
      void iFrame.contentWindow.location.href;
    } catch {
      showIFrame(iFrame, visibilityHandler);
    }
  }
}

In the assets/ts/workstations directory, create a new file named latency-measurement.ts and populate it with the below content:

latency-measurement.ts
import {
  getLatencyColor,
  setWSAutoTextAndFlag,
  updateButtonLabel,
  updateVPNAutoErrorDisplay,
  updateVPNAutoLocation,
} from './latency-utilities';
import { ERROR_SVG, HIDDEN } from './constants';
import {
  LatencyError,
  LatencyResult,
  pingLocations,
  PingResultCallbacks
} from './ping';

export function determineLatencies(container: HTMLElement): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>('[data-latency-id]');
  const locationIDs: string[] = [];

  latencyElements.forEach((latencyElement) => {
    const locationID = latencyElement.dataset.latencyId as string;

    if (locationID !== 'automatic') locationIDs.push(locationID);
  });

  const latencyResultCallbacks =
    getLatencyResultCallbacks(container, locationIDs.length);
  pingLocations(locationIDs, latencyResultCallbacks);
}

function getLatencyResultCallbacks(
  container: HTMLElement,
  totalLocations: number
): PingResultCallbacks {
  let lowestLatency = Infinity;
  let errorCount = 0;

  return {
    onResult: (latencyResult: LatencyResult) => {
      updateWSLatencyOnUI(container, latencyResult);

      if (latencyResult.latency < lowestLatency) {
        lowestLatency = latencyResult.latency;
        updateAutoLocations(container, latencyResult);
      }
    },
    onError: (latencyError: LatencyError) => {
      const { locationID } = latencyError;
      updateWSErrorDisplay(container, locationID);

      errorCount++;
      if (errorCount === totalLocations) {
        updateWSErrorDisplay(container, 'automatic');
        updateVPNAutoErrorDisplay();
      }
    },
  };
}

function updateWSLatencyOnUI(
  container: HTMLElement,
  latencyResult: LatencyResult): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>(
      `[data-latency-id="${latencyResult.locationID}"]`
    );

  latencyElements.forEach((latencyElement) => {
    const loadingSpinner =
      latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
    const latencyText =
      latencyElement.querySelector<HTMLElement>('.ws-latency-text');

    if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
    if (latencyText) {
      latencyText.textContent = `${latencyResult.latency} ms`;
      latencyText.className =
        `ws-latency-text text-xs ${getLatencyColor(latencyResult.latency)}`;
      latencyText.classList.remove(HIDDEN);
    }
  });
}

function updateWSErrorDisplay(
  container: HTMLElement,
  locationId: string
): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>(
      `[data-latency-id="${locationId}"]`
    );

  latencyElements.forEach((latencyElement) => {
    const loadingSpinner =
      latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
    const latencyText =
      latencyElement.querySelector<HTMLElement>('.ws-latency-text');

    if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
    if (latencyText) {
      latencyText.innerHTML = ERROR_SVG;
      latencyText.className = 'ws-latency-text text-xs';
      latencyText.classList.remove(HIDDEN);
    }

    if (locationId === 'automatic') {
      const button = latencyElement.closest('button');
      if (button) updateButtonLabel(button, 'Error');
    }
  });
}

function updateAutoLocations(
  container: HTMLElement,
  latencyResult: LatencyResult
): void {
  setWSAutoTextAndFlag(container, latencyResult.locationID);

  const automaticButton = container
    .querySelector<HTMLButtonElement>('button[data-auto-location]');

  const isAutoSelected = automaticButton?.classList.contains('selected');

  if (automaticButton) {
    automaticButton.dataset.value = latencyResult.locationID;
  }

  const locationInput = container
    .querySelector<HTMLInputElement>('#ws-location-input');
  if (locationInput && isAutoSelected) {
    locationInput.value = latencyResult.locationID;
  }

  const automaticLatencyResult: LatencyResult = {
    locationID: 'automatic',
    latency: latencyResult.latency,
  };

  updateWSLatencyOnUI(container, automaticLatencyResult);
  updateVPNAutoLocation(latencyResult.locationID);
}

Update live-view.ts to add the below new Phoenix hooks:

live-view.ts
hooks.ConnectToWS = getConnectToWSHook();
hooks.Countdown = getCountdownHook();
hooks.LoadingProgress = getLoadingProgressHook();
hooks.Turnstile = getTurnstileHook();
hooks.UILog = getUILogHook();

Now add the below hook functions to the bottom of the file:

live-view.ts
function getConnectToWSHook() {
  return {
    mounted(this: ViewHookInterface) {
      connectToWorkstation(this.el);
    }
  };
}

function getLoadingProgressHook() {
  return {
    mounted(this: ViewHookInterface) {
      initializeLoadingProgress(this.el);
    }
  };
}

function getCountdownHook() {
  let countdown: number | undefined;

  return {
    mounted(this: ViewHookInterface) {
      countdown = initializeCountdown(this.el);
    },
    destroyed() {
      clearInterval(countdown);
    }
  };
}

function getUILogHook() {
  return {
    mounted(this: ViewHookInterface) {
      initializeUILog(this);
    }
  };
}

function getTurnstileHook() {
  let turnstile: Turnstile | undefined;

  return {
    mounted(this: ViewHookInterface) {
      turnstile = initializeTurnstile(this.el);
    },
    destroyed() {
      turnstile?.removeWidget();
    }
  };
}

Phoenix LiveView dispatches phx:page-loading-start and phx:page-loading-stop events to show a progress bar (via topbar) during live navigation. To also show this progress bar when users click buttons or submit forms, use JS.push with the page_loading: true option instead of plain phx-click or phx-submit string events. Note that the phx-page-loading HTML attribute does not work in current versions of Phoenix LiveView.

Update launch.html.heex to use JS.push with page_loading: true on the form submit:

launch.html.heex
<form id="launch" phx-submit={JS.push("start_workstation", page_loading: true)} class="flex flex-col h-full">

Update workstations.html.heex to use JS.push with page_loading: true on the workstation selection:

workstations.html.heex
    phx-click={JS.push("select_workstation", value: %{ws_name: workstation}, page_loading: true)}

Update login.html.heex to use JS.push with page_loading: true on all OAuth buttons and the continue without account form. Apply this to all six OAuth provider buttons (gitlab, github, google, apple, azure, discord):

login.html.heex
                  phx-click={JS.push("login_with_oauth", value: %{provider: "gitlab"}, page_loading: true)}

And on the continue without account form:

login.html.heex
              <form class="w-full" phx-submit={JS.push("continue_without_account", page_loading: true)}>

Update overview.html.heex to use JS.push with page_loading: true on all sidebar and mobile navigation buttons. Apply this to all ten select_overview_view buttons (browsers, apps, desktops, sessions, upgrade — in both sidebar and mobile navigation):

overview.html.heex
                      phx-click={JS.push("select_overview_view", value: %{view: "browsers"}, page_loading: true)}

Update app.css to add a spin animation for the loading logo:

app.css
--animate-spin: spin 2.5s linear infinite;

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}