Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add identity and repo creation flow
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
  • Remove AuthenticationError component
  • Remove authenticate e2e test
  • Remove replacing standard html checkboxes with custom ones
  • Remove @public path alias
  • Reduce tauri log verbosity
    • Instead of logging to stdout everything, just do so until Info level
40 files changed +1648 -451 c730e821 β†’ efe3c51d
modified Cargo.lock
@@ -1970,6 +1970,18 @@ dependencies = [
]

[[package]]
+
name = "getrandom"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi 0.13.3+wasi-0.2.2",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4196,6 +4208,7 @@ dependencies = [
 "radicle-types",
 "serde",
 "serde_json",
+
 "ssh-key",
 "tauri",
 "tauri-build",
 "tauri-plugin-clipboard-manager",
@@ -4206,6 +4219,7 @@ dependencies = [
 "thiserror 1.0.69",
 "tokio",
 "ts-rs",
+
 "zeroize",
]

[[package]]
@@ -4224,6 +4238,9 @@ dependencies = [
 "serde",
 "serde_json",
 "sqlite",
+
 "ssh-key",
+
 "tauri-plugin-clipboard-manager",
+
 "tauri-plugin-fs",
 "tempfile",
 "thiserror 1.0.69",
 "tree-sitter-bash",
@@ -5517,9 +5534,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"

[[package]]
name = "tar"
-
version = "0.4.43"
+
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c65998313f8e17d0d553d28f91a0df93e4dbbbf770279c7bc21ca0f09ea1a1f6"
+
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
 "filetime",
 "libc",
@@ -5875,12 +5892,13 @@ dependencies = [

[[package]]
name = "tempfile"
-
version = "3.14.0"
+
version = "3.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
+
checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [
 "cfg-if",
 "fastrand",
+
 "getrandom 0.3.1",
 "once_cell",
 "rustix",
 "windows-sys 0.59.0",
@@ -6757,6 +6775,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"

[[package]]
+
name = "wasi"
+
version = "0.13.3+wasi-0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
+
dependencies = [
+
 "wit-bindgen-rt",
+
]
+

+
[[package]]
name = "wasm-bindgen"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7392,6 +7419,15 @@ dependencies = [
]

[[package]]
+
name = "wit-bindgen-rt"
+
version = "0.33.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
+
dependencies = [
+
 "bitflags 2.6.0",
+
]
+

+
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7642,6 +7678,9 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
dependencies = [
+
 "serde",
+
]

[[package]]
name = "zerovec"
modified crates/radicle-tauri/Cargo.toml
@@ -32,6 +32,8 @@ tauri-plugin-window-state = { version = "2.2.1" }
thiserror = { version = "1.0.64" }
tokio = { version = "1.40.0", features = ["time"] }
ts-rs = { version = "10.0.0", features = ["serde-json-impl", "no-serde-warnings"] }
+
ssh-key = { version = "0.6.3" }
+
zeroize = { version = "1.8.1", features = ["serde"] }

[features]
# by default Tauri runs in production mode
modified crates/radicle-tauri/src/commands.rs
@@ -2,7 +2,7 @@ pub mod auth;
pub mod cob;
pub mod diff;
pub mod inbox;
-
pub mod init;
pub mod profile;
pub mod repo;
+
pub mod startup;
pub mod thread;
modified crates/radicle-tauri/src/commands/auth.rs
@@ -1,9 +1,84 @@
+
use std::str::FromStr;
+

+
use radicle::crypto::ssh::{self, Passphrase};
+
use radicle::node::Alias;
+
use radicle::profile::env;
use radicle_types::error::Error;
-
use radicle_types::traits::auth::Auth;

use crate::AppState;

#[tauri::command]
-
pub(crate) fn authenticate(ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    ctx.authenticate().map_err(Error::from)
+
pub fn authenticate(
+
    ctx: tauri::State<AppState>,
+
    passphrase: Option<Passphrase>,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    if !profile.keystore.is_encrypted()? {
+
        return Ok(());
+
    }
+
    match ssh::agent::Agent::connect() {
+
        Ok(mut agent) => {
+
            if agent.request_identities()?.contains(&profile.public_key) {
+
                return Ok(());
+
            }
+

+
            match passphrase {
+
                Some(passphrase) => {
+
                    profile.keystore.secret_key(Some(passphrase.clone()))?;
+
                    register(&mut agent, profile, passphrase)
+
                }
+
                None => Err(Error::Crypto(
+
                    radicle::crypto::ssh::keystore::Error::PassphraseMissing,
+
                )),
+
            }
+
        }
+
        Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
+
        Err(e) => Err(e)?,
+
    }
+
}
+

+
#[tauri::command]
+
pub(crate) fn init(alias: String, passphrase: Passphrase) -> Result<(), Error> {
+
    let home = radicle::profile::home()?;
+
    let alias = Alias::from_str(&alias)?;
+

+
    if passphrase.is_empty() {
+
        return Err(Error::Crypto(
+
            radicle::crypto::ssh::keystore::Error::PassphraseMissing,
+
        ));
+
    }
+
    let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), env::seed())?;
+
    match ssh::agent::Agent::connect() {
+
        Ok(mut agent) => register(&mut agent, &profile, passphrase.clone())?,
+
        Err(e) if e.is_not_running() => return Err(Error::AgentNotRunning),
+
        Err(e) => Err(e)?,
+
    }
+

+
    Ok(())
+
}
+

+
pub fn register(
+
    agent: &mut ssh::agent::Agent,
+
    profile: &radicle::Profile,
+
    passphrase: ssh::Passphrase,
+
) -> Result<(), Error> {
+
    let secret = profile
+
        .keystore
+
        .secret_key(Some(passphrase))
+
        .map_err(|e| {
+
            if e.is_crypto_err() {
+
                Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
                    ssh_key::Error::Crypto,
+
                ))
+
            } else {
+
                e.into()
+
            }
+
        })?
+
        .ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
            ssh_key::Error::Crypto,
+
        )))?;
+

+
    agent.register(&secret)?;
+

+
    Ok(())
}
modified crates/radicle-tauri/src/commands/cob.rs
@@ -1,7 +1,5 @@
use std::path::PathBuf;

-
use anyhow::{Context, Result};
-

use radicle::git;
use radicle::identity;
use radicle_types as types;
@@ -44,8 +42,7 @@ pub async fn save_embed_by_clipboard(
    let content = app_handle
        .clipboard()
        .read_image()
-
        .map(|i| i.rgba().to_vec())
-
        .context("Not able to read the image from the clipboard")?;
+
        .map(|i| i.rgba().to_vec())?;

    ctx.save_embed_by_bytes(rid, name, content)
}
@@ -68,16 +65,15 @@ pub async fn save_embed_to_disk(
    oid: git::Oid,
    name: String,
) -> Result<(), Error> {
-
    let path = app_handle
+
    let Some(path) = app_handle
        .dialog()
        .file()
        .set_file_name(name)
        .blocking_save_file()
-
        .context("no path defined")?;
-

-
    let path = path
-
        .into_path()
-
        .context("Not able to convert into PathBuf")?;
+
    else {
+
        return Err(Error::SaveEmbedError);
+
    };
+
    let path = path.into_path()?;

    ctx.save_embed_to_disk(rid, oid, path)
}
deleted crates/radicle-tauri/src/commands/init.rs
@@ -1,114 +0,0 @@
-
use std::collections::BTreeMap;
-

-
use radicle::cob::cache::COBS_DB_FILE;
-
use radicle::identity::RepoId;
-
use radicle::node::{Handle, Node, NOTIFICATIONS_DB_FILE};
-
use radicle::storage::ReadStorage;
-
use tauri::{AppHandle, Emitter, Manager};
-

-
use radicle_types::config::Config;
-
use radicle_types::error::Error;
-
use radicle_types::traits::Profile;
-
use radicle_types::{domain, AppState};
-

-
#[tauri::command]
-
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
-
    let profile = radicle::Profile::load()?;
-

-
    let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
-
        profile.node().join(NOTIFICATIONS_DB_FILE),
-
    )?;
-
    let cob_db =
-
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
-

-
    let inbox_service = domain::inbox::service::Service::new(inbox_db);
-
    let patch_service = domain::patch::service::Service::new(cob_db);
-

-
    app.manage(inbox_service);
-
    app.manage(patch_service);
-

-
    let state = AppState { profile };
-
    app.manage(state.clone());
-

-
    Ok(state.config())
-
}
-

-
#[tauri::command]
-
pub(crate) fn node_status_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let app_handle = app.clone();
-

-
    let node = Node::new(ctx.profile.socket());
-
    let node_status = node.clone();
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            let _ = app_handle.emit("node_running", node_status.is_running());
-
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
-

-
#[tauri::command]
-
pub(crate) fn repo_sync_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-
    let repositories = profile.storage.repositories()?;
-

-
    let app_handle = app.clone();
-
    let public_key = profile.public_key;
-

-
    let node = Node::new(profile.socket());
-
    let mut node_seeds = node.clone();
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            let mut sync_status =
-
                BTreeMap::<RepoId, Option<radicle_types::cobs::repo::SyncStatus>>::new();
-
            for repo in &repositories {
-
                if let Ok(seeds) = node_seeds.seeds(repo.rid).map(Into::<Vec<_>>::into) {
-
                    if let Some(status) =
-
                        seeds
-
                            .iter()
-
                            .find_map(|radicle::node::Seed { nid, sync, .. }| {
-
                                (*nid == public_key).then_some(sync.clone())
-
                            })
-
                    {
-
                        sync_status.insert(repo.rid, status.map(Into::into));
-
                    } else {
-
                        // The local node wasn't found in the seed nodes table.
-
                        sync_status.insert(repo.rid, None);
-
                    }
-
                }
-
            }
-
            let _ = app_handle.emit("sync_status", sync_status);
-
            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
-

-
#[tauri::command]
-
pub(crate) fn node_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let app_handle = app.clone();
-
    let node = Node::new(ctx.profile.socket());
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            if node.is_running() {
-
                log::debug!("node: spawned node event subscription.");
-
                while let Ok(events) = node.subscribe(std::time::Duration::MAX) {
-
                    for event in events.into_iter().flatten() {
-
                        let _ = app_handle.emit("event", event);
-
                    }
-
                }
-
                log::debug!("node: event subscription loop has exited.");
-
            }
-

-
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
modified crates/radicle-tauri/src/commands/repo.rs
@@ -1,6 +1,13 @@
-
use radicle::git;
-
use radicle::identity::RepoId;
+
use std::collections::BTreeSet;
+
use std::str::FromStr;

+
use radicle::git;
+
use radicle::identity::project::ProjectName;
+
use radicle::identity::{doc, Project, RepoId, Visibility};
+
use radicle::rad::InitError;
+
use radicle::storage::git::Repository;
+
use radicle::storage::refs::branch_of;
+
use radicle::storage::{SignRepository, WriteRepository};
use radicle_types as types;
use radicle_types::error::Error;
use radicle_types::traits::repo::{Repo, Show};
@@ -47,3 +54,69 @@ pub async fn list_commits(
) -> Result<Vec<types::repo::Commit>, Error> {
    ctx.list_commits(rid, base, head)
}
+

+
#[tauri::command]
+
pub(crate) async fn create_repo(
+
    ctx: tauri::State<'_, AppState>,
+
    name: String,
+
    description: String,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    let storage = &profile.storage;
+
    let signer = ctx.profile.signer()?;
+
    let config = radicle::git::raw::Config::open_default()?;
+
    // SAFETY: "master" is always a valid RefString
+
    let default_branch = git::RefString::try_from(
+
        config
+
            .get_string("init.defaultBranch")
+
            .unwrap_or("master".to_owned()),
+
    )
+
    .unwrap();
+

+
    let name = ProjectName::from_str(&name)?;
+
    if description.len() > doc::MAX_STRING_LENGTH {
+
        return Err(Error::ProjectError(
+
            radicle::identity::project::ProjectError::Description("Cannot exceed 255 characters."),
+
        ));
+
    }
+

+
    let visibility = Visibility::Private {
+
        allow: BTreeSet::default(),
+
    };
+

+
    let proj = Project::new(name, description, default_branch.clone()).map_err(|errs| {
+
        InitError::ProjectPayload(
+
            errs.into_iter()
+
                .map(|err| err.to_string())
+
                .collect::<Vec<_>>()
+
                .join(", "),
+
        )
+
    })?;
+
    let doc = radicle::identity::Doc::initial(proj, profile.public_key.into(), visibility);
+
    let (project, identity) = Repository::init(&doc, &storage, &signer)?;
+

+
    let tree_id = {
+
        let mut index = project.backend.index()?;
+

+
        index.write_tree()
+
    }?;
+
    let sig = project.backend.signature()?;
+
    let tree = project.backend.find_tree(tree_id)?;
+

+
    project.set_remote_identity_root_to(signer.public_key(), identity)?;
+
    project.set_identity_head_to(identity)?;
+

+
    let base = project
+
        .backend
+
        .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
+

+
    let ns_head = branch_of(&ctx.profile.public_key, &default_branch);
+
    project
+
        .backend
+
        .reference(ns_head.as_str(), base, false, "Created namespace ref")?;
+

+
    project.set_head()?;
+
    project.sign_refs(&signer)?;
+

+
    Ok(())
+
}
added crates/radicle-tauri/src/commands/startup.rs
@@ -0,0 +1,93 @@
+
use std::collections::BTreeMap;
+

+
use radicle::cob::cache::COBS_DB_FILE;
+
use radicle::identity::RepoId;
+
use radicle::node::{Handle, Node, NOTIFICATIONS_DB_FILE};
+
use radicle::storage::ReadStorage;
+
use tauri::{AppHandle, Emitter, Manager};
+

+
use radicle_types::config::Config;
+
use radicle_types::error::Error;
+
use radicle_types::traits::Profile;
+
use radicle_types::{domain, AppState};
+

+
#[tauri::command]
+
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
+
    let profile = radicle::Profile::load()?;
+
    let repositories = profile.storage.repositories()?;
+
    let public_key = profile.public_key;
+

+
    let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
+
        profile.node().join(NOTIFICATIONS_DB_FILE),
+
    )?;
+
    let cob_db =
+
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+

+
    let inbox_service = domain::inbox::service::Service::new(inbox_db);
+
    let patch_service = domain::patch::service::Service::new(cob_db);
+

+
    let node_handle = app.app_handle().clone();
+
    let sync_handle = app.app_handle().clone();
+
    let events_handle = app.app_handle().clone();
+

+
    let node = Node::new(profile.socket());
+
    let node_status = node.clone();
+

+
    let mut node_seeds = node.clone();
+

+
    app.manage(inbox_service);
+
    app.manage(patch_service);
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            let _ = node_handle.emit("node_running", node_status.is_running());
+
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+
        }
+
    });
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            let mut sync_status =
+
                BTreeMap::<RepoId, Option<radicle_types::cobs::repo::SyncStatus>>::new();
+
            for repo in &repositories {
+
                if let Ok(seeds) = node_seeds.seeds(repo.rid).map(Into::<Vec<_>>::into) {
+
                    if let Some(status) =
+
                        seeds
+
                            .iter()
+
                            .find_map(|radicle::node::Seed { nid, sync, .. }| {
+
                                (*nid == public_key).then_some(sync.clone())
+
                            })
+
                    {
+
                        sync_status.insert(repo.rid, status.map(Into::into));
+
                    } else {
+
                        // The local node wasn't found in the seed nodes table.
+
                        sync_status.insert(repo.rid, None);
+
                    }
+
                }
+
            }
+
            let _ = sync_handle.emit("sync_status", sync_status);
+
            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
+
        }
+
    });
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            if node.is_running() {
+
                log::info!("node: spawned node event subscription.");
+
                while let Ok(events) = node.subscribe(std::time::Duration::MAX) {
+
                    for event in events.into_iter().flatten() {
+
                        let _ = events_handle.emit("event", event);
+
                    }
+
                }
+
                log::info!("node: event subscription loop has exited.");
+
            }
+

+
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+
        }
+
    });
+

+
    let state = AppState { profile };
+
    app.manage(state.clone());
+

+
    Ok(state.config())
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -2,14 +2,18 @@ mod commands;

use radicle_types::AppState;

-
use commands::{auth, cob, diff, inbox, init, profile, repo, thread};
+
use commands::{auth, cob, diff, inbox, profile, repo, startup, thread};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    #[cfg(debug_assertions)]
    let builder = tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
-
        .plugin(tauri_plugin_log::Builder::new().build());
+
        .plugin(
+
            tauri_plugin_log::Builder::new()
+
                .level(log::LevelFilter::Info)
+
                .build(),
+
        );
    #[cfg(not(debug_assertions))]
    let builder = tauri::Builder::default();

@@ -18,42 +22,43 @@ pub fn run() {
        .plugin(tauri_plugin_clipboard_manager::init())
        .plugin(tauri_plugin_window_state::Builder::default().build())
        .invoke_handler(tauri::generate_handler![
-
            init::startup,
-
            init::node_status_events,
-
            init::repo_sync_events,
-
            init::node_events,
            auth::authenticate,
-
            repo::repo_count,
-
            repo::list_repos,
-
            repo::repo_by_id,
-
            repo::diff_stats,
-
            repo::list_commits,
-
            diff::get_diff,
-
            inbox::list_notifications,
-
            inbox::count_notifications_by_repo,
-
            inbox::clear_notifications,
+
            auth::init,
            cob::get_embed,
-
            cob::save_embed_to_disk,
-
            cob::save_embed_by_path,
-
            cob::save_embed_by_clipboard,
-
            cob::save_embed_by_bytes,
            cob::issue::activity_by_issue,
-
            cob::issue::list_issues,
-
            cob::issue::issue_by_id,
            cob::issue::comment_threads_by_issue_id,
            cob::issue::create_issue,
            cob::issue::edit_issue,
+
            cob::issue::issue_by_id,
+
            cob::issue::list_issues,
            cob::patch::activity_by_patch,
+
            cob::patch::edit_patch,
            cob::patch::list_patches,
            cob::patch::patch_by_id,
            cob::patch::edit_patch,
            cob::patch::review_by_patch_and_revision_and_id,
            cob::patch::revisions_by_patch,
            cob::patch::revision_by_patch_and_id,
+
            cob::patch::revisions_by_patch,
+
            cob::save_embed_by_bytes,
+
            cob::save_embed_by_clipboard,
+
            cob::save_embed_by_path,
+
            cob::save_embed_to_disk,
+
            diff::get_diff,
+
            inbox::clear_notifications,
+
            inbox::count_notifications_by_repo,
+
            inbox::list_notifications,
+
            profile::alias,
+
            profile::config,
+
            repo::create_repo,
+
            repo::diff_stats,
+
            repo::list_commits,
+
            repo::list_repos,
+
            repo::repo_by_id,
+
            repo::repo_count,
+
            startup::startup,
            thread::create_issue_comment,
            thread::create_patch_comment,
-
            profile::config,
-
            profile::alias,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
modified crates/radicle-types/Cargo.toml
@@ -16,6 +16,9 @@ radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
sqlite = { version = "0.32.0", features = ["bundled"] }
+
ssh-key = { version = "0.6.3" }
+
tauri-plugin-clipboard-manager = { version = "2.2.1" }
+
tauri-plugin-fs = { version = "2.2.0" }
tempfile = { version = "3.14.0" }
thiserror = { version = "1.0.65" }
tree-sitter-bash = { version = "0.23.3" }
added crates/radicle-types/bindings/error/ErrorWrapper.ts
@@ -0,0 +1,3 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type ErrorWrapper = { code: string; message?: string };
modified crates/radicle-types/src/error.rs
@@ -9,7 +9,35 @@ use crate::cobs::stream;
pub enum Error {
    /// Profile error.
    #[error(transparent)]
-
    Profile(#[from] radicle::profile::Error),
+
    ProfileError(#[from] radicle::profile::Error),
+

+
    /// Missing SSH Agent error.
+
    #[error("ssh agent not running")]
+
    AgentNotRunning,
+

+
    /// Embeds error.
+
    #[error("not able to save embed")]
+
    SaveEmbedError,
+

+
    /// Init Error error.
+
    #[error(transparent)]
+
    InitError(#[from] radicle::rad::InitError),
+

+
    /// Alias error.
+
    #[error(transparent)]
+
    AliasError(#[from] radicle::node::AliasError),
+

+
    /// Tauri Plugin Clipboard error.
+
    #[error(transparent)]
+
    TauriPluginClipboard(#[from] tauri_plugin_clipboard_manager::Error),
+

+
    /// Tauri Plugin Fs error.
+
    #[error(transparent)]
+
    TauriPluginFs(#[from] tauri_plugin_fs::Error),
+

+
    /// Project error.
+
    #[error(transparent)]
+
    ProjectError(#[from] radicle::identity::project::ProjectError),

    /// List notification error.
    #[error(transparent)]
@@ -25,10 +53,6 @@ pub enum Error {
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),

-
    /// Anyhow error.
-
    #[error(transparent)]
-
    Anyhow(#[from] anyhow::Error),
-

    /// Io error.
    #[error(transparent)]
    Io(#[from] std::io::Error),
@@ -109,45 +133,60 @@ pub enum Error {
    #[error(transparent)]
    Node(#[from] radicle::node::Error),

-
    /// An error with a hint.
-
    #[error("{err} {hint}")]
-
    WithHint {
-
        err: anyhow::Error,
-
        hint: &'static str,
-
    },
-

    /// Serde JSON error.
    #[error(transparent)]
    SerdeJSON(#[from] serde_json::error::Error),
}

-
#[derive(Serialize)]
-
struct ErrorWrapperWithHint {
-
    err: String,
-
    hint: String,
+
impl Error {
+
    #[must_use]
+
    pub const fn code(&self) -> &'static str {
+
        match self {
+
            Error::ProjectError(radicle::identity::project::ProjectError::Name(_)) => {
+
                "ProjectError.InvalidName"
+
            }
+
            Error::ProjectError(radicle::identity::project::ProjectError::Description(_)) => {
+
                "ProjectError.InvalidDescription"
+
            }
+
            Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(ssh_key::Error::Crypto))
+
            | Error::Crypto(radicle::crypto::ssh::keystore::Error::PassphraseMissing) => {
+
                "PassphraseError.InvalidPassphrase"
+
            }
+
            Error::AliasError(radicle::node::AliasError::Empty) => "AliasError.EmptyAlias",
+
            Error::AliasError(radicle::node::AliasError::MaxBytesExceeded) => {
+
                "AliasError.TooLongAlias"
+
            }
+
            Error::ProfileError(radicle::profile::Error::NotFound(_)) => {
+
                "IdentityError.MissingProfile"
+
            }
+
            Error::AliasError(radicle::node::AliasError::InvalidCharacter) => {
+
                "AliasError.InvalidAlias"
+
            }
+
            _ => "UnknownError",
+
        }
+
    }
}

-
#[derive(Serialize)]
-
struct ErrorWrapper {
-
    err: String,
+
#[derive(Serialize, ts_rs::TS, Debug)]
+
#[ts(export)]
+
#[ts(export_to = "error/")]
+
pub struct ErrorWrapper {
+
    code: String,
+
    #[ts(optional)]
+
    message: Option<String>,
}

-
impl Serialize for Error {
+
impl serde::Serialize for Error {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::ser::Serializer,
    {
-
        match self {
-
            Error::WithHint { err, hint } => ErrorWrapperWithHint {
-
                err: err.to_string(),
-
                hint: hint.to_string(),
-
            }
-
            .serialize(serializer),
-
            err => ErrorWrapper {
-
                err: err.to_string(),
-
            }
-
            .serialize(serializer),
-
        }
+
        use serde::ser::SerializeStruct;
+

+
        let mut state = serializer.serialize_struct("ErrorWrapper", 2)?;
+
        state.serialize_field("code", &self.code().to_string())?;
+
        state.serialize_field("message", &self.to_string())?;
+
        state.end()
    }
}

@@ -164,14 +203,27 @@ impl IntoResponse for Error {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
-
    use super::Error;
-
    use anyhow::anyhow;
+
    use crate::error::Error;
+

+
    #[test]
+
    fn serialize_nested_errors() {
+
        let serialized = serde_json::to_string(&Error::Crypto(
+
            radicle::crypto::ssh::keystore::Error::Ssh(ssh_key::Error::Crypto),
+
        ))
+
        .unwrap();
+
        assert_eq!(
+
            serialized,
+
            "{\"code\":\"PassphraseError.InvalidPassphrase\",\"message\":\"ssh keygen: cryptographic error\"}"
+
        );
+
    }

    #[test]
-
    fn serialize_errors() {
-
        assert_eq!(serde_json::to_string(&Error::WithHint {
-
            err: anyhow!("Not able to find your keys in the ssh agent"),
-
            hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
-
        }).unwrap(),"{\"err\":\"Not able to find your keys in the ssh agent\",\"hint\":\"Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.\"}");
+
    fn serialize_unknown_errors() {
+
        let serialized =
+
            serde_json::to_string(&Error::Issue(radicle::issue::Error::MissingIdentity)).unwrap();
+
        assert_eq!(
+
            serialized,
+
            "{\"code\":\"UnknownError\",\"message\":\"identity document missing\"}"
+
        );
    }
}
modified crates/radicle-types/src/lib.rs
@@ -1,4 +1,3 @@
-
use traits::auth::Auth;
use traits::cobs::Cobs;
use traits::issue::{Issues, IssuesMut};
use traits::patch::{Patches, PatchesMut};
@@ -22,7 +21,6 @@ pub struct AppState {
    pub profile: radicle::Profile,
}

-
impl Auth for AppState {}
impl Repo for AppState {}
impl Thread for AppState {}
impl Cobs for AppState {}
modified crates/radicle-types/src/repo.rs
@@ -100,7 +100,7 @@ impl TryFrom<identity::doc::Payload> for ProjectPayloadData {
    type Error = error::Error;

    fn try_from(value: identity::doc::Payload) -> Result<Self, Self::Error> {
-
        serde_json::from_value::<Self>((*value).clone()).map_err(error::Error::from)
+
        serde_json::from_value::<Self>((*value).clone()).map_err(Into::into)
    }
}

modified crates/radicle-types/src/traits.rs
@@ -2,7 +2,6 @@ use radicle::node::{AliasStore, NodeId};

use crate::config::Config;

-
pub mod auth;
pub mod cobs;
pub mod issue;
pub mod patch;
deleted crates/radicle-types/src/traits/auth.rs
@@ -1,31 +0,0 @@
-
use anyhow::anyhow;
-

-
use radicle::crypto::ssh;
-

-
use crate::error::Error;
-

-
use super::Profile;
-

-
pub trait Auth: Profile {
-
    fn authenticate(&self) -> Result<(), Error> {
-
        let profile = &self.profile();
-

-
        if !profile.keystore.is_encrypted()? {
-
            return Ok(());
-
        }
-
        match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => {
-
            if agent.request_identities()?.contains(&profile.public_key) {
-
                Ok(())
-
            } else {
-
                Err(Error::WithHint {
-
                    err: anyhow!("Not able to find your keys in the ssh agent"),
-
                    hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
-
                })?
-
            }
-
        }
-
        Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?, 
-
        Err(e) => Err(e)?,
-
    }
-
    }
-
}
modified crates/test-http-api/src/api.rs
@@ -23,7 +23,6 @@ use radicle_types::domain::patch::service::Service;
use radicle_types::domain::patch::traits::PatchService;
use radicle_types::error::Error;
use radicle_types::outbound::sqlite::Sqlite;
-
use radicle_types::traits::auth::Auth;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::issue::{Issues, IssuesMut};
use radicle_types::traits::patch::{Patches, PatchesMut};
@@ -37,7 +36,6 @@ pub struct Context {
    patches: Arc<Service<Sqlite>>,
}

-
impl Auth for Context {}
impl Repo for Context {}
impl Cobs for Context {}
impl Thread for Context {}
@@ -107,9 +105,7 @@ async fn config_handler(State(ctx): State<Context>) -> impl IntoResponse {
    Ok::<_, Error>(Json(config))
}

-
async fn auth_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    ctx.authenticate()?;
-

+
async fn auth_handler() -> impl IntoResponse {
    Ok::<_, Error>(Json(()))
}

modified public/index.css
@@ -76,6 +76,16 @@ body {
  flex-shrink: 0;
}

+
.global-link {
+
  color: var(--color-foreground-default);
+
  text-decoration: none;
+
}
+
.global-link:hover {
+
  text-decoration: underline;
+
  text-decoration-thickness: 1px;
+
  text-underline-offset: 2px;
+
}
+

:root {
  --elevation-low: 0 0 48px 0 #000000ee;
}
modified src/App.svelte
@@ -1,79 +1,60 @@
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
  import type { Config } from "@bindings/config/Config";
  import type { UnlistenFn } from "@tauri-apps/api/event";
-
  import type { SyncStatus } from "@bindings/repo/SyncStatus";

-
  import { SvelteMap } from "svelte/reactivity";
  import { onDestroy, onMount } from "svelte";

-
  import { invoke } from "@app/lib/invoke";
-
  import { listen } from "@tauri-apps/api/event";
-

  import * as router from "@app/lib/router";
-
  import { nodeRunning, syncStatus } from "@app/lib/events";
+
  import { checkAuth, startup } from "@app/lib/auth.svelte";
+
  import { dynamicInterval } from "@app/lib/interval";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { invoke } from "@app/lib/invoke";
  import { theme } from "@app/components/ThemeSwitch.svelte";
  import { unreachable } from "@app/lib/utils";

-
  import AuthenticationError from "@app/views/AuthenticationError.svelte";
+
  import Auth from "@app/views/booting/Auth.svelte";
+
  import CreateIdentity from "@app/views/booting/CreateIdentity.svelte";
  import CreateIssue from "@app/views/repo/CreateIssue.svelte";
-
  import Inbox from "./views/home/Inbox.svelte";
+
  import Inbox from "@app/views/home/Inbox.svelte";
  import Issue from "@app/views/repo/Issue.svelte";
  import Issues from "@app/views/repo/Issues.svelte";
  import Patch from "@app/views/repo/Patch.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
-
  import Repos from "./views/home/Repos.svelte";
-
  import { dynamicInterval, checkAuth } from "./lib/auth";
+
  import Repos from "@app/views/home/Repos.svelte";

  const activeRouteStore = router.activeRouteStore;

+
  let profile = $state<Config>();
  let unlistenEvents: UnlistenFn | undefined = undefined;
  let unlistenNodeEvents: UnlistenFn | undefined = undefined;
  let unlistenSyncStatus: UnlistenFn | undefined = undefined;

-
  let error = $state<undefined | unknown>();
-

  onMount(async () => {
    try {
-
      await invoke<Config>("startup");
-
    } catch (e: unknown) {
-
      error = e;
+
      profile = await invoke<Config>("startup");
+
    } catch (err) {
+
      startup.error = err as ErrorWrapper;
      return;
    }

    if (window.__TAURI_INTERNALS__) {
-
      unlistenEvents = await listen("event", () => {
-
        // Add handler for incoming events
-
      });
-

-
      unlistenSyncStatus = await listen<Record<string, SyncStatus>>(
-
        "sync_status",
-
        event => {
-
          syncStatus.set(new SvelteMap(Object.entries(event.payload)));
-
        },
-
      );
-

-
      unlistenNodeEvents = await listen<boolean>("node_running", event => {
-
        nodeRunning.set(event.payload);
-
      });
+
      [unlistenEvents, unlistenNodeEvents, unlistenSyncStatus] =
+
        await createEventEmittersOnce();
    }

    try {
      await invoke("authenticate");
      void router.loadFromLocation();
-
      void dynamicInterval(
+
      dynamicInterval(
+
        "auth",
        checkAuth,
        import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
      );
-
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
    } catch (e: any) {
-
      void router.push({
-
        resource: "authenticationError",
-
        params: {
-
          error: e.err,
-
          hint: e.hint,
-
        },
-
      });
-
      void dynamicInterval(checkAuth, 1000);
+
    } catch (err) {
+
      startup.error = err as ErrorWrapper;
+
      void router.push({ resource: "booting" });
+
      dynamicInterval("auth", checkAuth, 5_000);
    }
  });

@@ -93,10 +74,11 @@
</script>

{#if $activeRouteStore.resource === "booting"}
-
  {#if error && typeof error === "object" && "err" in error && typeof error.err === "string"}
-
    <AuthenticationError error={error.err} />
+
  {#if startup.error?.code === "IdentityError.MissingProfile"}
+
    <CreateIdentity />
+
  {:else if startup.error?.code === "PassphraseError.InvalidPassphrase" && profile}
+
    <Auth profile={{ did: profile.publicKey, alias: profile.alias }} />
  {/if}
-
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
  <Repos {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "inbox"}
@@ -111,8 +93,6 @@
  <Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
  <Patches {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "authenticationError"}
-
  <AuthenticationError {...$activeRouteStore.params} />
{:else}
  {unreachable($activeRouteStore)}
{/if}
modified src/components/Icon.svelte
@@ -37,6 +37,7 @@
      | "face"
      | "file"
      | "home"
+
      | "info"
      | "inbox"
      | "issue"
      | "issue-closed"
@@ -583,6 +584,21 @@
    <path d="M6 9H10L10 10H6L6 9Z" />
    <path d="M3 13H13V14H3L3 13Z" />
    <path d="M3 2H13V3H3L3 2Z" />
+
  {:else if name === "info"}
+
    <path d="M10 13V14L6 14V13L10 13Z" />
+
    <path d="M4 12H6L6 13L4 13L4 12Z" />
+
    <path d="M3 10H4L4 12L3 12L3 10Z" />
+
    <path d="M3 6L3 10H2L2 6H3Z" />
+
    <path d="M4 4L4 6H3L3 4L4 4Z" />
+
    <path d="M6 3L6 4L4 4L4 3L6 3Z" />
+
    <path d="M10 3L6 3V2L10 2V3Z" />
+
    <path d="M12 4L10 4V3L12 3V4Z" />
+
    <path d="M13 6L12 6V4H13V6Z" />
+
    <path d="M13 10L13 6H14L14 10H13Z" />
+
    <path d="M12 12V10L13 10V12L12 12Z" />
+
    <path d="M12 12H10V13H12V12Z" />
+
    <path d="M9 7V10L10 10V11L6 11L6 10H7V8H6V7L9 7Z" />
+
    <path d="M9 4L7 4L7 6L9 6V4Z" />
  {:else if name === "issue"}
    <path d="M6 13H8V14H6V13Z" />
    <path d="M10 13L8 13V14L10 14V13Z" />
modified src/components/IssueTimeline.svelte
@@ -72,15 +72,6 @@
</script>

<style>
-
  a {
-
    color: var(--color-foreground-default);
-
    text-decoration: none;
-
  }
-
  a:hover {
-
    text-decoration: underline;
-
    text-decoration-thickness: 1px;
-
    text-underline-offset: 2px;
-
  }
  .timeline {
    display: flex;
    gap: 0.75rem;
@@ -234,7 +225,7 @@
          </div>
          <div class="wrapper">
            <NodeId {...authorForNodeId(op.author)} />
-
            <a href="#{op.id}">
+
            <a class="global-link" href="#{op.id}">
              {op.replyTo && op.replyTo !== activity[0].id
                ? "replied to a comment"
                : "commented"}
modified src/components/Markdown.svelte
@@ -134,20 +134,6 @@
        }
      }

-
      // Replace standard HTML checkboxes with our custom radicle-icon-small element
-
      for (const i of container.querySelectorAll('input[type="checkbox"]')) {
-
        i.parentElement?.classList.add("task-item");
-

-
        const checkbox = document.createElement("radicle-icon-small");
-
        const checked = i.getAttribute("checked");
-
        checkbox.setAttribute(
-
          "name",
-
          checked === null ? "checkbox-unchecked" : "checkbox-checked",
-
        );
-
        i.insertAdjacentElement("beforebegin", checkbox);
-
        i.remove();
-
      }
-

      // Replaces code blocks in the background with highlighted code.
      const prefix = "language-";
      const nodes = Array.from(document.body.querySelectorAll("pre code"));
added src/components/Spinner.svelte
@@ -0,0 +1,411 @@
+
<script lang="ts">
+
</script>
+

+
<svg role="img" width="16" height="16" viewBox="0 0 16 16">
+
  <rect fill="currentColor" width="1" height="1" transform="translate(3 2)">
+
    <animate
+
      keyTimes="0;0.1126;0.5394;0.6514;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="width"
+
      values="1;10;10;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.5394;0.6514;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m3.8465 2.8465l0 0l9 0"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.8465 -0.8465"
+
      to="-0.8465 -0.8465"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.6521;0.6635;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect fill="currentColor" width="1" height="1" transform="translate(2 3)">
+
    <animate
+
      keyTimes="0;0.1349;0.5394;0.6628;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="width"
+
      values="1;12;12;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.5394;0.6628;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m2.8465 3.8465l0 0l10 0"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.8465 -0.8465"
+
      to="-0.8465 -0.8465"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.6628;0.6743;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(13 3)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.1355;0.2475;0.6628;0.7417;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="height"
+
      values="1;1;10;10;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.6628;0.7417;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m13.5 3.5l0 9"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.1234;0.1355;0.7525;0.764;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(12 3)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.1463;0.2697;0.6743;0.7525;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="height"
+
      values="1;1;11;11;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.6743;0.7525;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m12.5 3.5l0 9.0001"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.1349;0.147;0.7525;0.764;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(12 13)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.2697;0.3823;0.764;0.8651;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="width"
+
      values="1;1;10;10;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.2697;0.3823;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m12.5 13.5l-9 0"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.2583;0.269;0.8651;0.8759;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(12 12)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.292;0.4046;0.7532;0.8658;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="width"
+
      values="1;1;11;11;1;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.292;0.4046;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m12.5 12.5l-10 0l0 0"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.2805;0.292;0.8658;0.8766;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1;0;0">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(2 12)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.4046;0.5172;0.8989;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="height"
+
      values="1;1;10;10;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.4046;0.5172;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m2.5 12.5q0 -6.7812 0 -8.9999"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.3931;0.4046;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1">
+
    </animate>
+
  </rect>
+
  <rect
+
    fill="currentColor"
+
    width="1"
+
    height="1"
+
    transform="translate(3 12)"
+
    opacity="0">
+
    <animate
+
      keyTimes="0;0.416;0.5394;0.8881;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="height"
+
      values="1;1;11;11;1">
+
    </animate>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="0 0"
+
      to="0 0"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite">
+
    </animateTransform>
+
    <animateMotion
+
      keyTimes="0;0.416;0.5394;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="transform"
+
      keyPoints="0;0;1;1"
+
      path="m3.5 12.5q0 -3.8947 0 -10"
+
      additive="sum">
+
    </animateMotion>
+
    <animateTransform
+
      attributeName="transform"
+
      type="translate"
+
      from="-0.5 -0.5"
+
      to="-0.5 -0.5"
+
      calcMode="discrete"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      additive="sum">
+
    </animateTransform>
+
    <animate
+
      keyTimes="0;0.3931;0.4046;1"
+
      calcMode="linear"
+
      dur="1186.4ms"
+
      repeatCount="indefinite"
+
      attributeName="opacity"
+
      values="0;0;1;1">
+
    </animate>
+
  </rect>
+
</svg>
modified src/components/TextInput.svelte
@@ -8,6 +8,7 @@
    name?: string;
    placeholder?: string;
    value?: string;
+
    type?: string;
    autofocus?: boolean;
    autoselect?: boolean;
    disabled?: boolean;
@@ -22,6 +23,7 @@
    name,
    placeholder,
    value = $bindable(undefined),
+
    type = "text",
    autofocus = false,
    autoselect = false,
    disabled = false,
@@ -98,7 +100,7 @@
      focussed = false;
    }}
    bind:this={inputElement}
-
    type="text"
+
    {type}
    {name}
    {placeholder}
    {disabled}
added src/lib/auth.svelte.ts
@@ -0,0 +1,37 @@
+
import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
import * as router from "@app/lib/router";
+
import { dynamicInterval } from "@app/lib/interval";
+
import { get } from "svelte/store";
+
import { invoke } from "@app/lib/invoke";
+

+
export const startup = $state<{ error?: ErrorWrapper }>({ error: undefined });
+

+
let lock = false;
+

+
export async function checkAuth() {
+
  try {
+
    if (lock) {
+
      return;
+
    }
+
    lock = true;
+
    await invoke("authenticate", { passphrase: "" });
+
    dynamicInterval(
+
      "auth",
+
      checkAuth,
+
      import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
+
    );
+
    if (get(router.activeRouteStore).resource === "booting") {
+
      void router.push({ resource: "home" });
+
    }
+
  } catch (err) {
+
    const error = err as ErrorWrapper;
+
    startup.error = error;
+
    if (get(router.activeRouteStore).resource !== "booting") {
+
      void router.push({ resource: "booting" });
+
    }
+
    dynamicInterval("auth", checkAuth, 5_000);
+
  } finally {
+
    lock = false;
+
  }
+
}
deleted src/lib/auth.ts
@@ -1,45 +0,0 @@
-
import { invoke } from "@app/lib/invoke";
-
import { activeRouteStore } from "@app/lib/router";
-
import { get } from "svelte/store";
-
import * as router from "@app/lib/router";
-

-
let intervalId: ReturnType<typeof setTimeout>;
-

-
export function dynamicInterval(callback: () => void, period: number) {
-
  clearTimeout(intervalId);
-

-
  intervalId = setTimeout(() => {
-
    callback();
-
    dynamicInterval(callback, period);
-
  }, period);
-
}
-

-
let lock = false;
-

-
export async function checkAuth() {
-
  try {
-
    if (lock) {
-
      return;
-
    }
-
    lock = true;
-
    await invoke("authenticate");
-
    if (get(activeRouteStore).resource === "authenticationError") {
-
      window.history.back();
-
    }
-
    dynamicInterval(checkAuth, import.meta.env.VITE_AUTH_LONG_DELAY || 30_000);
-
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  } catch (e: any) {
-
    if (get(activeRouteStore).resource !== "authenticationError") {
-
      await router.push({
-
        resource: "authenticationError",
-
        params: {
-
          error: e.err,
-
          hint: e.hint,
-
        },
-
      });
-
      dynamicInterval(checkAuth, 1000);
-
    }
-
  } finally {
-
    lock = false;
-
  }
-
}
added src/lib/interval.ts
@@ -0,0 +1,24 @@
+
const dynamicIntervals = new Map<string, ReturnType<typeof setTimeout>>();
+

+
export function dynamicInterval(
+
  key: string,
+
  callback: () => void,
+
  period: number,
+
) {
+
  // Clear an existing interval for this key, if any.
+
  if (dynamicIntervals.has(key)) {
+
    clearTimeout(dynamicIntervals.get(key));
+
  }
+

+
  // Set up a new dynamic interval.
+
  const id = setTimeout(() => {
+
    callback();
+
    dynamicInterval(key, callback, period);
+
  }, period);
+

+
  dynamicIntervals.set(key, id);
+
}
+

+
export function resetDynamicInterval(key: string) {
+
  dynamicIntervals.delete(key);
+
}
modified src/lib/markdown.ts
@@ -48,6 +48,7 @@ dompurify.setConfig({
    "ol",
    "p",
    "pre",
+
    "strong",
    "table",
    "tbody",
    "td",
modified src/lib/router.ts
@@ -113,9 +113,6 @@ function urlToRoute(url: URL): Route | null {
    case "repos": {
      return repoUrlToRoute(segments, url.searchParams);
    }
-
    case "authenticationError": {
-
      return { resource: "authenticationError", params: { error: "" } };
-
    }
    default: {
      return null;
    }
@@ -127,8 +124,6 @@ export function routeToPath(route: Route): string {
    return "/";
  } else if (route.resource === "inbox") {
    return "/inbox";
-
  } else if (route.resource === "authenticationError") {
-
    return "/authenticationError";
  } else if (
    route.resource === "repo.createIssue" ||
    route.resource === "repo.issue" ||
modified src/lib/router/definitions.ts
@@ -29,14 +29,6 @@ interface BootingRoute {
  resource: "booting";
}

-
interface AuthenticationErrorRoute {
-
  resource: "authenticationError";
-
  params: {
-
    error: string;
-
    hint?: string;
-
  };
-
}
-

interface HomeRoute {
  resource: "home";
  activeTab?: HomeReposTab;
@@ -76,15 +68,9 @@ interface LoadedHomeRoute {
  };
}

-
export type Route =
-
  | AuthenticationErrorRoute
-
  | InboxRoute
-
  | BootingRoute
-
  | HomeRoute
-
  | RepoRoute;
+
export type Route = InboxRoute | BootingRoute | HomeRoute | RepoRoute;

export type LoadedRoute =
-
  | AuthenticationErrorRoute
  | LoadedInboxRoute
  | BootingRoute
  | LoadedHomeRoute
added src/lib/startup.svelte.ts
@@ -0,0 +1,26 @@
+
import type { SyncStatus } from "@bindings/repo/SyncStatus";
+

+
import { listen } from "@tauri-apps/api/event";
+
import { SvelteMap } from "svelte/reactivity";
+
import { nodeRunning, syncStatus } from "./events";
+
import once from "lodash/once";
+

+
// Will be called once in the startup of the app
+
export const createEventEmittersOnce = once(async () => {
+
  const unlistenEvents = await listen("event", () => {
+
    // Add handler for incoming events
+
  });
+

+
  const unlistenSyncStatus = await listen<Record<string, SyncStatus>>(
+
    "sync_status",
+
    event => {
+
      syncStatus.set(new SvelteMap(Object.entries(event.payload)));
+
    },
+
  );
+

+
  const unlistenNodeEvents = await listen<boolean>("node_running", event => {
+
    nodeRunning.set(event.payload);
+
  });
+

+
  return [unlistenEvents, unlistenSyncStatus, unlistenNodeEvents];
+
});
deleted src/views/AuthenticationError.svelte
@@ -1,40 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-

-
  interface Props {
-
    error: string;
-
    hint?: string;
-
  }
-

-
  const { error, hint }: Props = $props();
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: center;
-
    height: 100%;
-
    width: 100%;
-
    row-gap: 0.5rem;
-
  }
-

-
  /* This tag comes from the backend. */
-
  :global(code) {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-small);
-
    background-color: var(--color-fill-ghost);
-
    padding: 0.125rem 0.25rem;
-
  }
-
</style>
-

-
<main>
-
  <Icon name="warning" size="32" />
-
  <div class="txt-medium txt-semibold">
-
    {error}
-
  </div>
-
  {#if hint}
-
    <div class="txt-small">{@html hint}</div>
-
  {/if}
-
</main>
added src/views/booting/Auth.svelte
@@ -0,0 +1,135 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  import * as router from "@app/lib/router";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import { truncateDid } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Spinner from "@app/components/Spinner.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import logo from "/radicle.svg?url";
+

+
  interface Props {
+
    profile: Author;
+
  }
+

+
  let error = $state<ErrorWrapper>();
+
  let passphrase = $state("");
+
  let authInProgress = $state(false);
+
  const { profile }: Props = $props();
+

+
  async function authenticate() {
+
    if (passphrase.length > 0) {
+
      authInProgress = true;
+
      error = undefined;
+
      try {
+
        await invoke("authenticate", { passphrase });
+
        if (window.__TAURI_INTERNALS__) {
+
          await createEventEmittersOnce();
+
        }
+
        passphrase = " ".repeat(passphrase.length);
+
        await router.push({ resource: "home" });
+
      } catch (err) {
+
        error = err as ErrorWrapper;
+
      } finally {
+
        authInProgress = false;
+
      }
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 1rem;
+
    text-align: center;
+
    padding-top: 10rem;
+
  }
+
  .logo {
+
    height: 3rem;
+
  }
+
  .text-center {
+
    text-align: center;
+
    margin: auto;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    margin-top: 1.5rem;
+
    width: 23rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <img src={logo} alt="Radicle Space Invader" class="logo" />
+

+
  <div class="txt-medium txt-bold">Unlock keys</div>
+
  <div class="txt-small">Your passphrase is needed to unlock your keys.</div>
+
  <div class="form">
+
    <div>
+
      <Border stylePadding="0.5rem 0.75rem" variant="ghost" flatBottom={true}>
+
        <div style:text-align="left">
+
          <div class="label txt-tiny">Alias</div>
+
          <div>
+
            {profile.alias}
+
          </div>
+
        </div>
+
      </Border>
+
      <Border stylePadding="0.5rem 0.75rem" variant="ghost" flatTop={true}>
+
        <div style:text-align="left">
+
          <div class="label txt-tiny">DID</div>
+
          <div>
+
            {truncateDid(profile.did)}
+
          </div>
+
        </div>
+
      </Border>
+
    </div>
+
    <div style:text-align="left">
+
      <div class="label txt-tiny">Passphrase</div>
+
      <TextInput
+
        autofocus
+
        onSubmit={authenticate}
+
        oninput={() => {
+
          error = undefined;
+
        }}
+
        placeholder="Enter passphrase to unlock your keys"
+
        type="password"
+
        bind:value={passphrase} />
+
      {#if error?.code === "PassphraseError.InvalidPassphrase"}
+
        <div
+
          style="color: var(--color-foreground-red);"
+
          class="hint txt-small global-flex">
+
          <Icon name="warning" />
+
          <span>Not able to decrypt keys with provided passphrase.</span>
+
        </div>
+
      {/if}
+
    </div>
+
    <Button
+
      disabled={authInProgress || passphrase.length === 0}
+
      variant="secondary"
+
      onclick={authenticate}>
+
      <div class="global-flex text-center">
+
        {#if authInProgress}
+
          <Spinner /> Unlocking…
+
        {:else}
+
          <Icon name="lock" /> Unlock
+
        {/if}
+
      </div>
+
    </Button>
+
  </div>
+
</div>
added src/views/booting/CreateIdentity.svelte
@@ -0,0 +1,218 @@
+
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import logo from "/radicle.svg?url";
+
  import { invoke } from "@app/lib/invoke";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { debounce } from "lodash";
+

+
  let passphrase = $state("");
+
  let notMatchingPassphrases = $state<boolean>();
+
  let passphraseRepeat = $state("");
+
  let alias = $state("");
+
  const errors = $state<{ alias: ErrorWrapper[]; passphrase: ErrorWrapper[] }>({
+
    alias: [],
+
    passphrase: [],
+
  });
+

+
  const validatePassphraseRepeat = debounce(() => {
+
    if (passphrase !== passphraseRepeat && passphraseRepeat.length !== 0) {
+
      notMatchingPassphrases = true;
+
    }
+
  }, 400);
+

+
  function validateInput(field: "alias" | "passphrase") {
+
    if (field === "alias" && alias.length === 0) {
+
      errors.alias.push({ code: "AliasError.EmptyAlias" });
+
    }
+
    if (field === "alias" && alias.length > 32) {
+
      errors.alias.push({ code: "AliasError.TooLongAlias" });
+
    }
+
    if (field === "alias" && alias.includes(" ")) {
+
      errors.alias.push({ code: "AliasError.InvalidAlias" });
+
    }
+
    if (field === "passphrase" && passphrase.length === 0) {
+
      errors.passphrase.push({ code: "PassphraseError.InvalidPassphrase" });
+
    }
+
  }
+

+
  const validAlias = $derived(
+
    alias.length > 0 && alias.length <= 32 && !alias.includes(" "),
+
  );
+
  const validPassphrase = $derived(
+
    passphrase.length > 0 && passphrase === passphraseRepeat,
+
  );
+

+
  async function handleKeydown() {
+
    if (passphrase !== passphraseRepeat) {
+
      notMatchingPassphrases = true;
+

+
      return;
+
    }
+
    try {
+
      await invoke("init", { passphrase, alias });
+
      await invoke("startup");
+
      await invoke("authenticate", { passphrase });
+
      // Clearing the passphrases from memory.
+
      passphrase = "";
+
      passphraseRepeat = "";
+

+
      if (window.__TAURI_INTERNALS__) {
+
        await createEventEmittersOnce();
+
      }
+

+
      void router.loadFromLocation();
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      if (e.code.startsWith("AliasError")) {
+
        errors.alias = [e];
+
      } else if (e.code.startsWith("PassphraseError")) {
+
        errors.passphrase = [e];
+
      }
+
      console.error(err);
+
    }
+

+
    return;
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 1rem;
+
    text-align: center;
+
    padding-top: 10rem;
+
  }
+
  .logo {
+
    height: 3rem;
+
  }
+
  .text-center {
+
    text-align: center;
+
    margin: auto;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    margin-top: 1.5rem;
+
    width: 23rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <img src={logo} alt="Radicle Space Invader" class="logo" />
+

+
  <div class="txt-medium txt-bold">Log in to Radicle Desktop</div>
+
  <div class="txt-small">
+
    Create a Radicle identity in order to use the app.
+
  </div>
+

+
  <div class="form">
+
    <div style:text-align="left">
+
      <div class="label txt-tiny">Alias (required)</div>
+
      <TextInput
+
        autofocus
+
        onSubmit={handleKeydown}
+
        oninput={() => {
+
          errors.alias = [];
+
          if (alias.length > 0) {
+
            validateInput("alias");
+
          }
+
        }}
+
        placeholder="Enter desired alias"
+
        type="text"
+
        bind:value={alias}></TextInput>
+
      {#if errors.alias.some(e => e.code.startsWith("AliasError"))}
+
        {#each errors.alias as error}
+
          <div
+
            style="color: var(--color-foreground-red);"
+
            class="hint txt-small global-flex">
+
            <Icon name="warning" />
+
            {#if error.code === "AliasError.EmptyAlias"}
+
              <span>Alias cannot be empty.</span>
+
            {:else if error.code === "AliasError.TooLongAlias"}
+
              <span>Alias is too long, make it less than 32 characters.</span>
+
            {:else if error.code === "AliasError.InvalidAlias"}
+
              <span>Alias cannot contain whitespace.</span>
+
            {/if}
+
          </div>
+
        {/each}
+
      {:else}
+
        <div class="hint txt-small txt-missing global-flex">
+
          <Icon name="info" /> Max 32 characters, no whitespace.
+
        </div>
+
      {/if}
+
    </div>
+
    <div>
+
      <div style:text-align="left" style:margin-bottom="0.5rem">
+
        <div class="label txt-tiny">Passphrase (required)</div>
+
        <TextInput
+
          onSubmit={handleKeydown}
+
          oninput={() => {
+
            errors.passphrase = [];
+
            notMatchingPassphrases = false;
+
            if (passphrase.length > 0) {
+
              validateInput("passphrase");
+
            }
+
          }}
+
          placeholder="Enter passphrase to protect your keys"
+
          type="password"
+
          bind:value={passphrase}></TextInput>
+
        {#if errors.passphrase.some(e => e.code.startsWith("PassphraseError"))}
+
          {#each errors.passphrase as error}
+
            <div
+
              style="color: var(--color-foreground-red);"
+
              class="hint txt-small global-flex">
+
              <Icon name="warning" />
+
              {#if error.code === "PassphraseError.InvalidPassphrase"}
+
                <span>Passphrase cannot be empty.</span>
+
              {:else}
+
                <span>{error.message}</span>
+
              {/if}
+
            </div>
+
          {/each}
+
        {/if}
+
      </div>
+
      <div style:text-align="left">
+
        <TextInput
+
          onSubmit={handleKeydown}
+
          oninput={() => {
+
            errors.passphrase = [];
+
            notMatchingPassphrases = false;
+
            validatePassphraseRepeat();
+
          }}
+
          placeholder="Repeat passphrase"
+
          type="password"
+
          bind:value={passphraseRepeat}></TextInput>
+
        {#if notMatchingPassphrases}
+
          <div
+
            style="color: var(--color-foreground-red);"
+
            class="hint txt-small global-flex">
+
            <Icon name="warning" /> Passphrases don't match
+
          </div>
+
        {/if}
+
      </div>
+
    </div>
+
    <Button
+
      disabled={!(validAlias && validPassphrase)}
+
      variant="secondary"
+
      onclick={handleKeydown}>
+
      <div class="global-flex text-center">
+
        <Icon name="seedling" />Create new identity
+
      </div>
+
    </Button>
+
  </div>
+
</div>
added src/views/home/Onboarding.svelte
@@ -0,0 +1,226 @@
+
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  import initialize from "@app/views/home/initialize.md?raw";
+
  import { invoke } from "@app/lib/invoke";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Tab from "@app/components/Tab.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";
+

+
  const { reload }: { reload: () => Promise<void> } = $props();
+

+
  const errors = $state<{
+
    repoName: ErrorWrapper[];
+
    repoDescription: ErrorWrapper[];
+
  }>({
+
    repoName: [],
+
    repoDescription: [],
+
  });
+
  let tab = $state<"create" | "init">("init");
+
  let repoName = $state<string>("");
+
  let repoDescription = $state<string>("");
+

+
  function validateInput(field: "name" | "description") {
+
    if (field === "name" && !validRepoName) {
+
      errors.repoName.push({ code: "ProjectError.InvalidName" });
+
    }
+
    if (field === "description" && !validRepoDescription) {
+
      errors.repoDescription.push({ code: "ProjectError.InvalidDescription" });
+
    }
+
  }
+

+
  const validRepoName = $derived(/^[a-zA-Z0-9._-]+$/.test(repoName));
+
  const validRepoDescription = $derived(repoDescription.length <= 255);
+

+
  async function createRepo() {
+
    try {
+
      await invoke("create_repo", {
+
        name: repoName,
+
        description: repoDescription,
+
      });
+
      void reload();
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      if (e.code === "ProjectError.InvalidName") {
+
        errors.repoName.push(e);
+
      } else if (e.code === "ProjectError.InvalidDescription") {
+
        errors.repoDescription.push(e);
+
      }
+
      console.error(err);
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
  }
+
  .tab {
+
    height: 24px;
+
    color: var(--color-foreground-contrast);
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
  .create-repo-container {
+
    display: flex;
+
    gap: 2rem;
+
  }
+
</style>
+

+
{#snippet tabSnippet(name: typeof tab, content: string)}
+
  <Tab
+
    active={tab === name}
+
    onclick={() => {
+
      tab = name;
+
    }}>
+
    <span class="tab">{content}</span>
+
  </Tab>
+
{/snippet}
+

+
<div class="txt-missing txt-small" style:margin-bottom="1.5rem">
+
  You don't have any repositories in your Radicle storage yet. To get started,
+
  try one of the options below.
+
</div>
+
<Border
+
  stylePosition="relative"
+
  variant="ghost"
+
  flatBottom
+
  styleDisplay="flex"
+
  styleWidth="100%"
+
  styleGap="1rem"
+
  stylePadding="0 1rem">
+
  {@render tabSnippet("create", "Create a new repo")}
+
  {@render tabSnippet("init", "Initialize existing repo")}
+
</Border>
+

+
<Border
+
  variant="ghost"
+
  flatTop
+
  stylePadding="1rem"
+
  styleDisplay="block"
+
  styleFlexDirection="column"
+
  styleAlignItems="flex-start">
+
  {#if tab === "create"}
+
    <div class="txt-small create-repo-container">
+
      <div class="container" style="width: 50%;">
+
        <div>Create a new repo initialized with Radicle.</div>
+

+
        <div class="form">
+
          <div style:text-align="left">
+
            <div class="label txt-tiny">Repository name (required)</div>
+
            <TextInput
+
              bind:value={repoName}
+
              oninput={() => {
+
                errors.repoName = [];
+
                validateInput("name");
+
              }}
+
              placeholder="Name of your repo" />
+
            {#if errors.repoName.length > 0}
+
              {#each errors.repoName as error}
+
                {#if error.code === "ProjectError.InvalidName" && repoName.length > 0}
+
                  <div
+
                    style="color: var(--color-foreground-red);"
+
                    class="hint txt-small global-flex">
+
                    <Icon name="warning" />
+
                    <span>
+
                      Only alphanumeric characters, '-', '_' and '.' are
+
                      allowed.
+
                    </span>
+
                  </div>
+
                {/if}
+
              {/each}
+
            {/if}
+
          </div>
+

+
          <div style:text-align="left">
+
            <div class="label txt-tiny">Description</div>
+
            <Textarea
+
              borderVariant="ghost"
+
              placeholder="Add description"
+
              oninput={() => {
+
                errors.repoDescription = [];
+
                validateInput("description");
+
              }}
+
              submit={async () => {
+
                await invoke("create_repo", {
+
                  name: repoName,
+
                  description: repoDescription,
+
                  defaultBranch: "master",
+
                });
+
                void reload();
+
              }}
+
              bind:value={repoDescription} />
+
            {#if errors.repoDescription.length > 0}
+
              {#each errors.repoDescription as error}
+
                <div
+
                  style="color: var(--color-foreground-red);"
+
                  class="hint txt-small global-flex">
+
                  <Icon name="warning" />
+
                  {#if error.code === "ProjectError.InvalidDescription"}
+
                    <span>Description cannot exceed 255 characters.</span>
+
                  {:else}
+
                    <span>{error.message}</span>
+
                  {/if}
+
                </div>
+
              {/each}
+
            {/if}
+
          </div>
+
          <div style:width="max-content">
+
            <Button
+
              disabled={!(validRepoDescription && validRepoName)}
+
              variant="secondary"
+
              onclick={createRepo}>
+
              Create new repo
+
            </Button>
+
          </div>
+
        </div>
+
      </div>
+
      <Border
+
        styleHeight="max-content"
+
        styleDisplay="flex"
+
        styleFlexDirection="column"
+
        styleAlignItems="flex-start"
+
        stylePadding="1rem"
+
        styleGap="0.5rem"
+
        variant="float"
+
        styleBackgroundColor="var(--color-background-float)">
+
        <div>πŸ‘Ύ</div>
+
        <div class="txt-bold txt-regular">Did you know?</div>
+
        <div>
+
          This repository will be stored in Radicle's local storage as a bare
+
          Git repository, so you don’t need to choose a folder or manually
+
          create one. Later, you can create a checkout to work with the
+
          repository as needed.
+
          <p>
+
            Want to learn more about how Radicle storage works compared to a
+
            regular Git working copy?
+
          </p>
+
          <!-- For handling whitespace -->
+
          <!-- prettier-ignore -->
+
          <span>Check out the <a target="_blank" class="txt-missing global-link" href="https://radicle.xyz/guides/protocol#local-first-storage">Protocol Guide</a>.</span>
+
        </div>
+
      </Border>
+
    </div>
+
  {:else}
+
    <div class="container txt-small">
+
      <Markdown rid="" content={initialize} />
+
    </div>
+
  {/if}
+
</Border>
modified src/views/home/Repos.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
  import type { HomeReposTab } from "@app/lib/router/definitions";
  import type { Config } from "@bindings/config/Config";
  import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
@@ -7,13 +8,15 @@

  import * as router from "@app/lib/router";
  import { didFromPublicKey } from "@app/lib/utils";
+
  import { dynamicInterval } from "@app/lib/interval";
+
  import { invoke } from "@app/lib/invoke";
+
  import { onMount } from "svelte";

  import CopyableId from "@app/components/CopyableId.svelte";
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Layout from "@app/views/repo/Layout.svelte";
+
  import Onboarding from "@app/views/home/Onboarding.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";
-
  import Border from "@app/components/Border.svelte";
-
  import Icon from "@app/components/Icon.svelte";

  interface Props {
    activeTab?: HomeReposTab;
@@ -24,9 +27,49 @@
  }

  /* eslint-disable prefer-const */
-
  let { config, repos, notificationCount, repoCount, activeTab }: Props =
+
  let {
+
    config,
+
    repos: initialRepos,
+
    notificationCount,
+
    repoCount,
+
    activeTab,
+
  }: Props =
    /* eslint-enable prefer-const */
    $props();
+

+
  let repos = $state(initialRepos);
+
  let lock = false;
+
  const startup = $state<{ error?: ErrorWrapper }>({ error: undefined });
+

+
  async function checkRepos() {
+
    try {
+
      if (lock) {
+
        return;
+
      }
+
      if (repos.length > 0) {
+
        return;
+
      }
+
      lock = true;
+
      await reload();
+
    } catch (err) {
+
      const error = err as ErrorWrapper;
+
      startup.error = error;
+
    } finally {
+
      lock = false;
+
    }
+
  }
+

+
  onMount(() => {
+
    dynamicInterval("repos", checkRepos, 5_000);
+
  });
+

+
  async function reload() {
+
    [repos, repoCount, config] = await Promise.all([
+
      invoke<RepoInfo[]>("list_repos", { show: "all" }),
+
      invoke<RepoCount>("repo_count"),
+
      invoke<Config>("config"),
+
    ]);
+
  }
</script>

<style>
@@ -65,7 +108,7 @@
  {/snippet}
  <div class="container">
    <div class="header">Repositories</div>
-
    {#if repos.length}
+
    {#if repos.length > 0}
      <div class="repo-grid">
        {#each repos as repo}
          {#if repo.payloads["xyz.radicle.project"]}
@@ -83,20 +126,7 @@
        {/each}
      </div>
    {:else}
-
      <Border
-
        variant="ghost"
-
        styleAlignItems="center"
-
        styleJustifyContent="center">
-
        <div
-
          class="global-flex"
-
          style:height="74px"
-
          style:justify-content="center">
-
          <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
-
            <Icon name="none" />
-
            No repositories.
-
          </div>
-
        </div>
-
      </Border>
+
      <Onboarding {reload} />
    {/if}
  </div>
</Layout>
added src/views/home/initialize.md
@@ -0,0 +1,42 @@
+
### 1. Install Radicle CLI
+

+
Run the following command in your terminal to install Radicle:
+

+
```sh
+
$ curl -sSf https://radicle.xyz/install | sh
+
```
+

+
### 2. Verify The Installation
+

+
Ensure Radicle CLI is installed correctly by checking its version:
+

+
```sh
+
$ rad --version
+
rad 1.1.0 (70f0cc35)
+
```
+

+
### 3. Initialize Your Repository
+

+
Navigate to your existing Git repository and initialize it with Radicle by following the setup prompts:
+

+
- **Repository Name:** Enter a name for your repository.
+
- **Description:** Provide a brief summary of what your repository does.
+
- **Default Branch:** Typically **main** or **master**.
+
- **Visibility:** Choose **public** to share with others or **private** to restrict access.
+

+
```sh
+
$ cd path/to/your/repository
+
$ rad init
+
```
+

+
### 4. Retrieve Your Repository Identifier (RID)
+

+
After initialization, your repository will show up here.  
+
You can retrieve its unique RID at any time:
+

+
```sh
+
$ rad .
+
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
+
```
+

+
That's it! Your repository is now a Radicle repo. πŸš€
deleted tests/e2e/authenticate.spec.ts
@@ -1,21 +0,0 @@
-
import { test, expect } from "@tests/support/fixtures.js";
-

-
test("removing identities from ssh-agent and re-adding them", async ({
-
  page,
-
  peer,
-
}) => {
-
  await page.goto("/");
-
  await expect(
-
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
-
  ).toBeVisible();
-

-
  await peer.logOut();
-
  await expect(
-
    page.getByText("Not able to find your keys in the ssh agent"),
-
  ).toBeVisible();
-

-
  await peer.authenticate();
-
  await expect(
-
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
-
  ).toBeVisible();
-
});
modified tsconfig.json
@@ -21,7 +21,6 @@
    "paths": {
      "@app/*": ["./src/*"],
      "@bindings/*": ["./crates/radicle-types/bindings/*"],
-
      "@public/*": ["./public/*"],
      "@tests/*": ["./tests/*"]
    }
  }
modified vite.config.ts
@@ -26,7 +26,6 @@ export default defineConfig({
    alias: {
      "@app": path.resolve("./src"),
      "@bindings": path.resolve("./crates/radicle-types/bindings/"),
-
      "@public": path.resolve("./public"),
    },
  },
});