Build the Synapsis Tauri onboarding and authentication shell with the official wordmark and a fluid stable-topology signal mesh
Hop-State: A_06FPFZF1AB46ARJ4HD8ZQH8 Hop-Proposal: R_06FPFZEBFD47ZTA2XKJ2Q0G Hop-Task: T_06FPFXR242APZTT77HV934R Hop-Attempt: AT_06FPFXR241F9H1V2J0RCGGG
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "synapsis-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop client for the Synapsis federated social network"
|
||||
authors = ["Gnosys Labs"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "synapsis_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" rx="224" fill="#080909"/>
|
||||
<g fill="none" stroke="#f7f7f3" stroke-width="28" stroke-linejoin="round">
|
||||
<path d="M512 166 318 278 512 390Z" fill="#f7f7f3"/>
|
||||
<path d="m540 166 194 112-194 112Z"/>
|
||||
<path d="m290 326 194 112-194 112Z"/>
|
||||
<path d="m762 326-194 112 194 112Z" fill="#f7f7f3"/>
|
||||
<path d="m512 486-194 112 194 112Z"/>
|
||||
<path d="m540 486 194 112-194 112Z" fill="#f7f7f3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 946 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,256 @@
|
||||
use reqwest::{Client, Method, StatusCode, Url};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
|
||||
struct AppState {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NodeInfo {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
accent_color: Option<String>,
|
||||
domain: Option<String>,
|
||||
logo_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct User {
|
||||
id: String,
|
||||
handle: String,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionPayload {
|
||||
user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectionPayload {
|
||||
node_url: String,
|
||||
node: NodeInfo,
|
||||
user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthPayload {
|
||||
user: User,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorPayload {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegistrationRequest<'a> {
|
||||
handle: &'a str,
|
||||
display_name: &'a str,
|
||||
email: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
fn normalize_node(input: &str) -> Result<Url, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Enter a valid Synapsis node URL.".into());
|
||||
}
|
||||
|
||||
let candidate = if trimmed.contains("://") {
|
||||
trimmed.to_owned()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let mut url = Url::parse(&candidate).map_err(|_| "Enter a valid Synapsis node URL.")?;
|
||||
if url.scheme() != "http" && url.scheme() != "https" {
|
||||
return Err("Synapsis nodes must use an http or https URL.".into());
|
||||
}
|
||||
if url.host_str().is_none() {
|
||||
return Err("Enter a valid Synapsis node URL.".into());
|
||||
}
|
||||
if url.host_str() == Some("www.synapsis.social") {
|
||||
url.set_host(Some("synapsis.social"))
|
||||
.map_err(|_| "Enter a valid Synapsis node URL.")?;
|
||||
}
|
||||
url.set_username("").ok();
|
||||
url.set_password(None).ok();
|
||||
url.set_path("/");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn endpoint(base: &Url, path: &str) -> Result<Url, String> {
|
||||
base.join(path)
|
||||
.map_err(|_| "The selected node address is invalid.".into())
|
||||
}
|
||||
|
||||
async fn decode_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, String> {
|
||||
let status = response.status();
|
||||
let bytes = response.bytes().await.map_err(|error| error.to_string())?;
|
||||
if !status.is_success() {
|
||||
let message = serde_json::from_slice::<ErrorPayload>(&bytes)
|
||||
.ok()
|
||||
.and_then(|payload| payload.error)
|
||||
.unwrap_or_else(|| format!("Request failed ({status})."));
|
||||
return Err(message);
|
||||
}
|
||||
serde_json::from_slice(&bytes).map_err(|_| "The node returned an unexpected response.".into())
|
||||
}
|
||||
|
||||
async fn get<T: DeserializeOwned>(client: &Client, base: &Url, path: &str) -> Result<T, String> {
|
||||
let response = client
|
||||
.get(endpoint(base, path)?)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Couldn’t reach this node: {error}"))?;
|
||||
decode_response(response).await
|
||||
}
|
||||
|
||||
async fn post<T: DeserializeOwned, B: Serialize>(
|
||||
client: &Client,
|
||||
base: &Url,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let response = client
|
||||
.request(Method::POST, endpoint(base, path)?)
|
||||
.header("Accept", "application/json")
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Couldn’t reach this node: {error}"))?;
|
||||
decode_response(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
state: State<'_, AppState>,
|
||||
node_url: String,
|
||||
) -> Result<ConnectionPayload, String> {
|
||||
let base = normalize_node(&node_url)?;
|
||||
let node = get::<NodeInfo>(&state.client, &base, "/api/node").await?;
|
||||
let session = get::<SessionPayload>(&state.client, &base, "/api/auth/me")
|
||||
.await
|
||||
.unwrap_or(SessionPayload { user: None });
|
||||
Ok(ConnectionPayload {
|
||||
node_url: base.to_string(),
|
||||
node,
|
||||
user: session.user,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn login(
|
||||
state: State<'_, AppState>,
|
||||
node_url: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<AuthPayload, String> {
|
||||
let base = normalize_node(&node_url)?;
|
||||
let payload = post::<SessionPayload, _>(
|
||||
&state.client,
|
||||
&base,
|
||||
"/api/auth/login",
|
||||
&serde_json::json!({ "email": email.trim(), "password": password }),
|
||||
)
|
||||
.await?;
|
||||
payload
|
||||
.user
|
||||
.map(|user| AuthPayload { user })
|
||||
.ok_or_else(|| "The node returned an unexpected response.".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn register(
|
||||
state: State<'_, AppState>,
|
||||
node_url: String,
|
||||
handle: String,
|
||||
display_name: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<AuthPayload, String> {
|
||||
let base = normalize_node(&node_url)?;
|
||||
let body = RegistrationRequest {
|
||||
handle: handle.trim().trim_start_matches('@'),
|
||||
display_name: display_name.trim(),
|
||||
email: email.trim(),
|
||||
password: &password,
|
||||
};
|
||||
let payload =
|
||||
post::<SessionPayload, _>(&state.client, &base, "/api/auth/register", &body).await?;
|
||||
payload
|
||||
.user
|
||||
.map(|user| AuthPayload { user })
|
||||
.ok_or_else(|| "The node returned an unexpected response.".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> {
|
||||
let base = normalize_node(&node_url)?;
|
||||
let response = state
|
||||
.client
|
||||
.post(endpoint(&base, "/api/auth/logout")?)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Couldn’t reach this node: {error}"))?;
|
||||
if response.status().is_success() || response.status() == StatusCode::UNAUTHORIZED {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Log out failed ({}).", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let client = Client::builder()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent("Synapsis Desktop/0.1")
|
||||
.build()
|
||||
.expect("failed to build Synapsis HTTP client");
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(AppState { client })
|
||||
.invoke_handler(tauri::generate_handler![connect, login, register, logout])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Synapsis");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_bare_node_names() {
|
||||
assert_eq!(
|
||||
normalize_node("synapsis.social").unwrap().as_str(),
|
||||
"https://synapsis.social/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_paths_credentials_and_fragments() {
|
||||
assert_eq!(
|
||||
normalize_node("https://person:secret@example.com/somewhere?q=1#fragment")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_web_schemes() {
|
||||
assert!(normalize_node("file:///tmp/node").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
synapsis_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Synapsis",
|
||||
"version": "0.1.0",
|
||||
"identifier": "xyz.gnosyslabs.synapsis",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1430",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Synapsis",
|
||||
"width": 1080,
|
||||
"height": 720,
|
||||
"minWidth": 720,
|
||||
"minHeight": 560,
|
||||
"center": true,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' asset: https: http: data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||