diff --git a/README.md b/README.md
index 75d9818df61..4a6075cddfc 100644
--- a/README.md
+++ b/README.md
@@ -196,6 +196,9 @@ The following environment variables configure Livebook:
Enabled by default unless `LIVEBOOK_PASSWORD` is set. Set it to "false" to
disable it.
+ * LIVEBOOK_UPDATE_INSTRUCTIONS_URL - sets the URL to direct the user to for
+ updating Livebook when a new version becomes available.
+
If running Livebook as a Docker image or an Elixir release, [the environment
diff --git a/lib/livebook.ex b/lib/livebook.ex
index 236449d99dd..bc95987a662 100644
--- a/lib/livebook.ex
+++ b/lib/livebook.ex
@@ -155,6 +155,11 @@ defmodule Livebook do
:app_service_url,
Livebook.Config.app_service_url!("LIVEBOOK_APP_SERVICE_URL")
end
+
+ if update_instructions_url =
+ Livebook.Config.update_instructions_url!("LIVEBOOK_UPDATE_INSTRUCTIONS_URL") do
+ config :livebook, :update_instructions_url, update_instructions_url
+ end
end
@doc """
diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex
index 63dd6e87513..ee722dc6f97 100644
--- a/lib/livebook/application.ex
+++ b/lib/livebook/application.ex
@@ -18,16 +18,18 @@ defmodule Livebook.Application do
LivebookWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Livebook.PubSub},
+ # Start a supervisor for Livebook tasks
+ {Task.Supervisor, name: Livebook.TaskSupervisor},
# Start the storage module
Livebook.Storage.current(),
- # Periodid measurement of system resources
+ # Start the periodic version check
+ Livebook.UpdateCheck,
+ # Periodic measurement of system resources
Livebook.SystemResources,
# Start the tracker server on this node
{Livebook.Tracker, pubsub_server: Livebook.PubSub},
# Start the supervisor dynamically managing sessions
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
- # Start a supervisor for Livebook tasks
- {Task.Supervisor, name: Livebook.TaskSupervisor},
# Start the server responsible for associating files with sessions
Livebook.Session.FileGuard,
# Start the Node Pool for managing node names
diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex
index 49af9635455..0eaa2ce8891 100644
--- a/lib/livebook/config.ex
+++ b/lib/livebook/config.ex
@@ -122,6 +122,14 @@ defmodule Livebook.Config do
Application.fetch_env!(:livebook, :app_service_url)
end
+ @doc """
+ Returns the update check URL.
+ """
+ @spec update_instructions_url() :: String.t() | nil
+ def update_instructions_url() do
+ Application.get_env(:livebook, :update_instructions_url)
+ end
+
## Parsing
@doc """
@@ -254,6 +262,13 @@ defmodule Livebook.Config do
System.get_env(env)
end
+ @doc """
+ Parses update instructions url from env.
+ """
+ def update_instructions_url!(env) do
+ System.get_env(env)
+ end
+
@doc """
Parses and validates default runtime from env.
"""
diff --git a/lib/livebook/settings.ex b/lib/livebook/settings.ex
index 11d8afa899a..cf270cf9249 100644
--- a/lib/livebook/settings.ex
+++ b/lib/livebook/settings.ex
@@ -13,7 +13,7 @@ defmodule Livebook.Settings do
@doc """
Returns the current autosave path.
"""
- @spec autosave_path() :: String.t() | nil
+ @spec autosave_path() :: String.t()
def autosave_path() do
case storage().fetch_key(:settings, "global", :autosave_path) do
{:ok, value} -> value
@@ -98,4 +98,23 @@ defmodule Livebook.Settings do
{:error, message} -> raise ArgumentError, "invalid S3 filesystem: #{message}"
end
end
+
+ @doc """
+ Returns whether the update check is enabled.
+ """
+ @spec update_check_enabled?() :: boolean()
+ def update_check_enabled?() do
+ case storage().fetch_key(:settings, "global", :update_check_enabled) do
+ {:ok, value} -> value
+ :error -> true
+ end
+ end
+
+ @doc """
+ Sets whether the update check is enabled.
+ """
+ @spec set_update_check_enabled(boolean()) :: :ok
+ def set_update_check_enabled(enabled) do
+ storage().insert(:settings, "global", update_check_enabled: enabled)
+ end
end
diff --git a/lib/livebook/update_check.ex b/lib/livebook/update_check.ex
new file mode 100644
index 00000000000..01e94e2dbea
--- /dev/null
+++ b/lib/livebook/update_check.ex
@@ -0,0 +1,157 @@
+defmodule Livebook.UpdateCheck do
+ @moduledoc false
+
+ # Periodically checks for available Livebook update.
+
+ use GenServer
+
+ require Logger
+
+ @name __MODULE__
+ @timeout :infinity
+
+ @hour_in_ms 60 * 60 * 1000
+ @day_in_ms 24 * @hour_in_ms
+
+ @doc false
+ def start_link(_opts) do
+ GenServer.start_link(__MODULE__, {}, name: @name)
+ end
+
+ @doc """
+ Returns the latest Livebook version if it's more recent than the
+ current one.
+ """
+ @spec new_version() :: String.t() | nil
+ def new_version() do
+ GenServer.call(@name, :get_new_version, @timeout)
+ end
+
+ @doc """
+ Returns whether the update check is enabled.
+ """
+ @spec enabled?() :: boolean()
+ def enabled?() do
+ GenServer.call(@name, :get_enabled, @timeout)
+ end
+
+ @doc """
+ Sets whether the update check is enabled.
+ """
+ @spec set_enabled(boolean()) :: :ok
+ def set_enabled(enabled) do
+ GenServer.cast(@name, {:set_enabled, enabled})
+ end
+
+ @impl true
+ def init({}) do
+ state = %{
+ enabled: Livebook.Settings.update_check_enabled?(),
+ new_version: nil,
+ timer_ref: nil,
+ request_ref: nil
+ }
+
+ {:ok, schedule_check(state, 0)}
+ end
+
+ @impl true
+ def handle_cast({:set_enabled, enabled}, state) do
+ Livebook.Settings.set_update_check_enabled(enabled)
+ state = %{state | enabled: enabled}
+ state = state |> cancel_check() |> schedule_check(0)
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_call(:get_enabled, _from, state) do
+ {:reply, state.enabled, state}
+ end
+
+ @impl true
+ def handle_call(:get_new_version, _from, state) do
+ new_version = if state.enabled, do: state.new_version
+ {:reply, new_version, state}
+ end
+
+ @impl true
+ def handle_info(:check, state) do
+ task = Task.Supervisor.async_nolink(Livebook.TaskSupervisor, &fetch_latest_version/0)
+ {:noreply, %{state | request_ref: task.ref}}
+ end
+
+ def handle_info({ref, response}, %{request_ref: ref} = state) do
+ Process.demonitor(ref, [:flush])
+
+ state =
+ case response do
+ {:ok, version} ->
+ new_version = if newer?(version), do: version
+ state = %{state | new_version: new_version}
+ schedule_check(state, @day_in_ms)
+
+ {:error, error} ->
+ Logger.error("version check failed, #{error}")
+ schedule_check(state, @hour_in_ms)
+ end
+
+ {:noreply, %{state | request_ref: nil}}
+ end
+
+ def handle_info({:DOWN, ref, :process, _pid, reason}, %{request_ref: ref} = state) do
+ Logger.error("version check failed, reason: #{inspect(reason)}")
+ {:noreply, %{state | request_ref: nil} |> schedule_check(@hour_in_ms)}
+ end
+
+ def handle_info(_msg, state), do: {:noreply, state}
+
+ defp schedule_check(%{enabled: false} = state, _time), do: state
+
+ defp schedule_check(state, time) do
+ timer_ref = Process.send_after(self(), :check, time)
+ %{state | timer_ref: timer_ref}
+ end
+
+ defp cancel_check(%{timer_ref: nil} = state), do: state
+
+ defp cancel_check(state) do
+ if Process.cancel_timer(state.timer_ref) == false do
+ receive do
+ :check -> :ok
+ end
+ end
+
+ %{state | timer_ref: nil}
+ end
+
+ defp fetch_latest_version() do
+ url = "https://api.github.com/repos/livebook-dev/livebook/releases/latest"
+ headers = [{"accept", "application/vnd.github.v3+json"}]
+
+ case Livebook.Utils.HTTP.request(:get, url, headers: headers) do
+ {:ok, status, _headers, body} ->
+ with 200 <- status,
+ {:ok, data} <- Jason.decode(body),
+ %{"tag_name" => "v" <> version} <- data do
+ {:ok, version}
+ else
+ _ -> {:error, "unexpected response"}
+ end
+
+ {:error, reason} ->
+ {:error, "failed to make a request, reason: #{inspect(reason)}"}
+ end
+ end
+
+ defp newer?(version) do
+ current_version = Application.spec(:livebook, :vsn) |> List.to_string()
+ stable?(version) and Version.compare(current_version, version) == :lt
+ end
+
+ defp stable?(version) do
+ case Version.parse(version) do
+ {:ok, %{pre: []}} -> true
+ _ -> false
+ end
+ end
+end
diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex
index 232a7d4b6fe..b8de0ba907d 100644
--- a/lib/livebook_web/live/home_live.ex
+++ b/lib/livebook_web/live/home_live.ex
@@ -26,6 +26,8 @@ defmodule LivebookWeb.HomeLive do
sessions: sessions,
notebook_infos: notebook_infos,
page_title: "Livebook",
+ new_version: Livebook.UpdateCheck.new_version(),
+ update_instructions_url: Livebook.Config.update_instructions_url(),
app_service_url: Livebook.Config.app_service_url(),
memory: Livebook.SystemResources.memory()
)}
@@ -39,9 +41,10 @@ defmodule LivebookWeb.HomeLive do