diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7da4516..e552ba3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5435,6 +5435,7 @@ dependencies = [ "futures-util", "hex", "keyring", + "reqwest 0.13.2", "rusqlite", "secp256k1", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28d7fb7..bfbd22a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,18 +18,21 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools", "tray-icon"] } +tauri = { version = "2", features = ["devtools", "tray-icon", "macos-proxy"] } # Resolves the per-platform data/config roots before Tauri exists — the identifier # migration must run before tauri::Builder, so it cannot use app.path(). dirs = "6" tauri-plugin-opener = "2" tauri-plugin-updater = "2" +# The updater uses reqwest 0.13. Enabling its SOCKS feature here is unified with +# that transitive dependency, so updater checks and downloads accept socks5://. +reqwest = { version = "0.13", default-features = false, features = ["socks"] } tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } rusqlite = { version = "0.32", features = ["bundled"] } -tauri-plugin-http = "2.5.7" +tauri-plugin-http = { version = "2.5.7", features = ["socks"] } tauri-plugin-dialog = "2.7.0" tauri-plugin-fs = "2.5.0" tauri-plugin-notification = "2.3.3" @@ -42,4 +45,3 @@ futures-util = "0.3" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0" - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c232005..be27aaa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Mutex; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + WebviewUrl, WebviewWindowBuilder, Manager, WindowEvent, }; @@ -113,6 +114,128 @@ fn migrate_legacy_data_dirs() { } } +// ── Network proxy ─────────────────────────────────────────────────────────── + +const PROXY_SETTINGS_FILE: &str = "proxy.json"; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ProxySettings { + enabled: bool, + url: String, +} + +impl Default for ProxySettings { + fn default() -> Self { + Self { + enabled: false, + url: String::new(), + } + } +} + +fn proxy_settings_path(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|dir| dir.join(PROXY_SETTINGS_FILE)) + .map_err(|e| e.to_string()) +} + +fn load_proxy_settings(path: std::path::PathBuf) -> ProxySettings { + let Ok(raw) = std::fs::read_to_string(path) else { + return ProxySettings::default(); + }; + serde_json::from_str(&raw).unwrap_or_default() +} + +fn load_proxy_settings_from_disk(app: &tauri::AppHandle) -> ProxySettings { + let Ok(path) = proxy_settings_path(app) else { + return ProxySettings::default(); + }; + load_proxy_settings(path) +} + +fn validate_proxy_settings(settings: &ProxySettings) -> Result<(), String> { + if !settings.enabled { + return Ok(()); + } + + let url = settings.url.trim(); + if url.is_empty() { + return Err("Proxy URL is required when proxy is enabled".into()); + } + + let parsed = url + .parse::() + .map_err(|_| "Proxy URL must be a valid http:// or socks5:// URL".to_string())?; + match parsed.scheme() { + "http" | "socks5" => {} + _ => return Err("Proxy URL must start with http:// or socks5://".into()), + } + if parsed.host_str().is_none() { + return Err("Proxy URL must include a host".into()); + } + if parsed.port_or_known_default().is_none() { + return Err("Proxy URL must include a port".into()); + } + + Ok(()) +} + +fn enabled_proxy_url(settings: &ProxySettings) -> Result, String> { + if !settings.enabled { + return Ok(None); + } + + validate_proxy_settings(settings)?; + settings + .url + .trim() + .parse::() + .map(Some) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_proxy_settings(app: tauri::AppHandle) -> Result { + Ok(load_proxy_settings_from_disk(&app)) +} + +#[tauri::command] +fn save_proxy_settings(app: tauri::AppHandle, settings: ProxySettings) -> Result<(), String> { + validate_proxy_settings(&settings)?; + let path = proxy_settings_path(&app)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let normalized = ProxySettings { + enabled: settings.enabled, + url: settings.url.trim().to_string(), + }; + let raw = serde_json::to_string_pretty(&normalized).map_err(|e| e.to_string())?; + std::fs::write(path, raw).map_err(|e| e.to_string()) +} + +fn create_main_window(app: &tauri::App) -> tauri::Result { + let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("Vega") + .inner_size(1200.0, 800.0) + .min_inner_size(900.0, 600.0); + + match enabled_proxy_url(&load_proxy_settings_from_disk(app.handle())) { + Ok(Some(url)) => { + builder = builder.proxy_url(url); + } + Ok(None) => {} + Err(e) => { + eprintln!("[proxy] Ignoring invalid saved proxy setting: {}", e); + } + } + + builder.build() +} + // ── OS keychain ───────────────────────────────────────────────────────────── // Keep legacy keyring service name so existing users don't lose their keys @@ -569,6 +692,8 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_notification::init()) .setup(|app| { + let main_window = create_main_window(app)?; + // ── SQLite ─────────────────────────────────────────────────────── let data_dir = app.path().app_data_dir()?; let conn = open_db(data_dir.clone()) @@ -588,7 +713,6 @@ pub fn run() { // ── WebKit memory tuning for Linux (webkit2gtk) ────────────────── #[cfg(target_os = "linux")] { - let main_window = app.get_webview_window("main").unwrap(); main_window.with_webview(|webview| { use webkit2gtk::{CacheModel, SettingsExt, WebContextExt, WebViewExt}; let wv = webview.inner(); @@ -651,9 +775,8 @@ pub fn run() { // ── Close → hide to tray ───────────────────────────────────────── // Closing the window hides it instead of exiting. Use "Quit" in the // tray menu (or ⌘Q / Alt-F4) to fully exit. - let window = app.get_webview_window("main").unwrap(); - let window_clone = window.clone(); - window.on_window_event(move |event| { + let window_clone = main_window.clone(); + main_window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window_clone.hide(); @@ -663,6 +786,8 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + get_proxy_settings, + save_proxy_settings, store_nsec, load_nsec, delete_nsec, @@ -689,7 +814,7 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{migrate_dir, APP_IDENTIFIER, LEGACY_IDENTIFIER}; + use super::{enabled_proxy_url, migrate_dir, ProxySettings, APP_IDENTIFIER, LEGACY_IDENTIFIER}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -817,5 +942,30 @@ mod tests { assert!(!new.exists(), "must not fabricate a data dir for a new user"); fs::remove_dir_all(&root).ok(); } -} + #[test] + fn accepts_http_and_socks5_proxy_urls() { + for url in ["http://127.0.0.1:8118", "socks5://127.0.0.1:9050"] { + let settings = ProxySettings { + enabled: true, + url: url.into(), + }; + assert!(enabled_proxy_url(&settings).unwrap().is_some(), "{url}"); + } + } + + #[test] + fn rejects_socks5h_until_webview_support_is_available() { + let settings = ProxySettings { + enabled: true, + url: "socks5h://127.0.0.1:9050".into(), + }; + + assert!(enabled_proxy_url(&settings).is_err()); + } + + #[test] + fn updater_http_client_supports_socks5_proxy_urls() { + assert!(reqwest::Proxy::all("socks5://127.0.0.1:9050").is_ok()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4333a31..046d75e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,15 +10,7 @@ "frontendDist": "../dist" }, "app": { - "windows": [ - { - "title": "Vega", - "width": 1200, - "height": 800, - "minWidth": 900, - "minHeight": 600 - } - ], + "windows": [], "security": { "csp": null } diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index 37db34a..4ec0285 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import { invoke } from "@tauri-apps/api/core"; +import { relaunch } from "@tauri-apps/plugin-process"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; import { useWoTStore } from "../../stores/wot"; @@ -11,6 +12,7 @@ import { useBookmarkStore } from "../../stores/bookmark"; import { getStoredRelayUrls } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { profileName } from "../../lib/utils"; +import { refreshProxySettingsCache, type ProxySettings } from "../../lib/proxy"; import { NWCWizard } from "./NWCWizard"; import { getNotificationSettings, saveNotificationSettings, ensurePermission } from "../../lib/notifications"; import { @@ -408,6 +410,139 @@ function NotificationSection() { ); } +function validateProxyUrl(enabled: boolean, url: string): string | null { + if (!enabled) return null; + const trimmed = url.trim(); + if (!trimmed) return "Proxy URL is required"; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "socks5:" && parsed.protocol !== "http:") { + return "Use socks5:// or http://"; + } + if (!parsed.hostname) return "Proxy URL must include a host"; + if (!parsed.port) return "Proxy URL must include a port"; + } catch { + return "Proxy URL is invalid"; + } + return null; +} + +function ProxySection() { + const [settings, setSettings] = useState({ enabled: false, url: "" }); + const [initial, setInitial] = useState({ enabled: false, url: "" }); + const [status, setStatus] = useState<"loading" | "idle" | "saving" | "saved" | "error">("loading"); + const [error, setError] = useState(null); + + useEffect(() => { + invoke("get_proxy_settings") + .then((loaded) => { + setSettings(loaded); + setInitial(loaded); + setStatus("idle"); + }) + .catch((err) => { + setError(String(err)); + setStatus("error"); + }); + }, []); + + const validationError = validateProxyUrl(settings.enabled, settings.url); + const dirty = settings.enabled !== initial.enabled || settings.url.trim() !== initial.url.trim(); + const savedNeedsRestart = status === "saved" && !dirty; + + const saveSettings = async () => { + if (validationError) { + setError(validationError); + return; + } + setStatus("saving"); + setError(null); + const next = { enabled: settings.enabled, url: settings.url.trim() }; + try { + await invoke("save_proxy_settings", { settings: next }); + refreshProxySettingsCache(); + setSettings(next); + setInitial(next); + setStatus("saved"); + } catch (err) { + setError(String(err)); + setStatus("error"); + } + }; + + const toggle = () => { + setSettings((current) => ({ ...current, enabled: !current.enabled })); + setError(null); + if (status === "saved") setStatus("idle"); + }; + + const updateUrl = (value: string) => { + setSettings((current) => ({ ...current, url: value })); + setError(null); + if (status === "saved") setStatus("idle"); + }; + + return ( +
+

+ Network Proxy +

+

+ Route Vega's in-app network traffic through an HTTP or SOCKS5 proxy. +

+ +
+ updateUrl(e.target.value)} + placeholder="socks5://127.0.0.1:9050" + disabled={!settings.enabled || status === "loading"} + className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50 placeholder:text-text-dim disabled:opacity-50 disabled:cursor-not-allowed font-mono" + /> + +
+

+ Applies after restart. Example: socks5://127.0.0.1:9050 +

+ {(error || validationError) && ( +

{error ?? validationError}

+ )} + {savedNeedsRestart && ( +
+

Saved. Restart Vega to apply it to all connections.

+ +
+ )} +
+ ); +} + function ThemeSection() { const { themeId, setTheme } = useUIStore(); @@ -594,6 +729,7 @@ export function SettingsView() { + diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index fbfe513..7d0f8b0 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { invoke } from "@tauri-apps/api/core"; +import { getProxySettings } from "../lib/proxy"; interface InstallInfo { can_self_update: boolean; @@ -20,6 +21,12 @@ interface UpdateState { dismiss: () => void; } +async function checkForUpdate() { + const settings = await getProxySettings(); + const proxy = settings.enabled ? settings.url.trim() : ""; + return check(proxy ? { proxy } : undefined); +} + export function useUpdater(): UpdateState { const [available, setAvailable] = useState(false); const [version, setVersion] = useState(null); @@ -42,7 +49,7 @@ export function useUpdater(): UpdateState { // Check for updates ~5 s after startup (non-blocking) const t = setTimeout(async () => { try { - const update = await check(); + const update = await checkForUpdate(); if (update?.available) { setAvailable(true); setVersion(update.version); @@ -59,7 +66,7 @@ export function useUpdater(): UpdateState { setInstalling(true); setError(null); try { - const update = await check(); + const update = await checkForUpdate(); if (update?.available) { await update.downloadAndInstall(); await relaunch(); diff --git a/src/lib/nostr/vertex.ts b/src/lib/nostr/vertex.ts index 2e6c5aa..93d9d6f 100644 --- a/src/lib/nostr/vertex.ts +++ b/src/lib/nostr/vertex.ts @@ -1,4 +1,4 @@ -import { fetch } from "@tauri-apps/plugin-http"; +import { fetchWithProxy as fetch } from "../proxy"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { getNDK } from "./core"; import { debug } from "../debug"; diff --git a/src/lib/podcast/fountainFm.ts b/src/lib/podcast/fountainFm.ts index 4fa82f6..dc1cdaf 100644 --- a/src/lib/podcast/fountainFm.ts +++ b/src/lib/podcast/fountainFm.ts @@ -1,4 +1,4 @@ -import { fetch } from "@tauri-apps/plugin-http"; +import { fetchWithProxy as fetch } from "../proxy"; import type { PodcastEpisode } from "../../types/podcast"; import { enrichWithV4V } from "./podcastIndexV4V"; diff --git a/src/lib/podcast/podcastIndexV4V.ts b/src/lib/podcast/podcastIndexV4V.ts index 311ed57..65e0643 100644 --- a/src/lib/podcast/podcastIndexV4V.ts +++ b/src/lib/podcast/podcastIndexV4V.ts @@ -1,4 +1,4 @@ -import { fetch } from "@tauri-apps/plugin-http"; +import { fetchWithProxy as fetch } from "../proxy"; import type { PodcastEpisode, V4VRecipient } from "../../types/podcast"; const API_KEY = "VKWWTGY25NVCKYJWHSNY"; diff --git a/src/lib/podcast/v4v.ts b/src/lib/podcast/v4v.ts index 8f2a27c..62fd226 100644 --- a/src/lib/podcast/v4v.ts +++ b/src/lib/podcast/v4v.ts @@ -1,4 +1,4 @@ -import { fetch } from "@tauri-apps/plugin-http"; +import { fetchWithProxy as fetch } from "../proxy"; import type { PodcastEpisode, V4VRecipient } from "../../types/podcast"; import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc"; import { useV4VStore } from "../../stores/v4v"; diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts new file mode 100644 index 0000000..d143e8c --- /dev/null +++ b/src/lib/proxy.ts @@ -0,0 +1,43 @@ +import { invoke } from "@tauri-apps/api/core"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; + +export interface ProxySettings { + enabled: boolean; + url: string; +} + +type TauriFetchInput = Parameters[0]; +type TauriFetchInit = Parameters[1]; +type TauriFetchInitWithProxy = NonNullable & { + proxy?: { + all?: string; + }; +}; + +let proxySettingsPromise: Promise | null = null; + +export function getProxySettings(): Promise { + if (!proxySettingsPromise) { + proxySettingsPromise = invoke("get_proxy_settings") + .then((settings) => settings ?? { enabled: false, url: "" }) + .catch(() => ({ enabled: false, url: "" })); + } + return proxySettingsPromise; +} + +export function refreshProxySettingsCache(): void { + proxySettingsPromise = null; +} + +export async function fetchWithProxy(input: TauriFetchInput, init?: TauriFetchInit): Promise { + const settings = await getProxySettings(); + if (!settings.enabled || !settings.url.trim()) { + return tauriFetch(input, init); + } + + const proxiedInit: TauriFetchInitWithProxy = { + ...(init ?? {}), + proxy: { all: settings.url.trim() }, + }; + return tauriFetch(input, proxiedInit); +} diff --git a/src/lib/tauri-dev-mock.ts b/src/lib/tauri-dev-mock.ts index 0fb0b70..bf0ae30 100644 --- a/src/lib/tauri-dev-mock.ts +++ b/src/lib/tauri-dev-mock.ts @@ -11,6 +11,7 @@ if (import.meta.env.DEV && !(window as any).__TAURI_INTERNALS__) { const keychainKey = (pubkey: string) => `__dev_nsec_${pubkey}`; + const proxyKey = "__dev_proxy_settings"; const mockInvoke = async (cmd: string, args: Record = {}): Promise => { switch (cmd) { @@ -22,6 +23,13 @@ if (import.meta.env.DEV && !(window as any).__TAURI_INTERNALS__) { case "delete_nsec": localStorage.removeItem(keychainKey(args.pubkey as string)); return null; + case "get_proxy_settings": { + const stored = localStorage.getItem(proxyKey); + return stored ? JSON.parse(stored) : { enabled: false, url: "" }; + } + case "save_proxy_settings": + localStorage.setItem(proxyKey, JSON.stringify(args.settings)); + return null; case "db_save_notes": case "db_save_profile": return null; diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 74d1a00..4d062f5 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -1,4 +1,4 @@ -import { fetch } from "@tauri-apps/plugin-http"; +import { fetchWithProxy as fetch } from "./proxy"; import { getNDK } from "./nostr"; import { NDKEvent } from "@nostr-dev-kit/ndk";