Add optional Tor (arti) routing on Android
Adds an opt-in Tor mode that routes all app traffic through a local SOCKS5 proxy backed by arti (Tor in Rust), bundled via the org.torproject:arti-mobile:1.7.0.1 AAR. - TorController starts/stops arti and installs a fail-closed WebView proxy override (no direct fallback) so traffic can't leak while Tor is connecting or down. Connectivity is verified against check.torproject.org (IsTor) and re-checked continuously; the exit IP is surfaced for verification, and the status isn't latched so a dropped circuit downgrades honestly. - TorPlugin bridges enable/disable/status to the Capacitor/JS layer. Toggling applies live, in place, with no app restart. - UI: a slim fail-closed status banner (replacing the old full-screen gate), the Tor toggle in Advanced settings reachable while logged out, and Settings/Search/About added to the logged-out menu. - R8 keep rules for org.torproject.arti.** so the JNI native-method classes aren't stripped/renamed; androidx.webkit on the compile classpath for the WebView proxy APIs.
This commit is contained in:
@@ -16,6 +16,13 @@ android {
|
||||
versionCode 1
|
||||
versionName "2.8.9"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
// The arti-mobile AAR bundles large native Rust libraries for every
|
||||
// ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
|
||||
// arm64-v8a + armeabi-v7a (real devices) and x86_64 (emulators).
|
||||
// Drop x86_64 here if you only ever test on physical devices.
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
|
||||
}
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
@@ -56,6 +63,13 @@ dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo.
|
||||
// Provides org.torproject.arti.ArtiProxy used by TorController.
|
||||
implementation 'org.torproject:arti-mobile:1.7.0.1'
|
||||
// arti pulls androidx.webkit in transitively but only at runtime; we
|
||||
// compile against ProxyController/WebViewFeature in TorController, so
|
||||
// declare it explicitly on the app's compile classpath.
|
||||
implementation "androidx.webkit:webkit:$androidxWebkitVersion"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
Vendored
+4
@@ -27,6 +27,10 @@
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.outsystems.plugins.barcode.** { *; }
|
||||
|
||||
# Keep arti (Tor) classes — ArtiJNI declares native methods invoked from the
|
||||
# Rust .so via JNI, so its names must not be obfuscated/stripped.
|
||||
-keep class org.torproject.arti.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
@@ -25,6 +25,14 @@ public class MainActivity extends BridgeActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(TorPlugin.class);
|
||||
|
||||
// If the user enabled Tor (apply on relaunch), start arti BEFORE
|
||||
// super.onCreate so the WebView SOCKS proxy override is installed
|
||||
// before the WebView issues any network request — no leak window.
|
||||
if (TorController.isEnabled(this)) {
|
||||
TorController.getInstance().start(getApplicationContext());
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package spot.agora.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.webkit.ProxyConfig;
|
||||
import androidx.webkit.ProxyController;
|
||||
import androidx.webkit.WebViewFeature;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Proxy;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.torproject.arti.ArtiProxy;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* Process-wide controller for the optional Tor (arti) mode on Android.
|
||||
*
|
||||
* <p>When enabled, this starts a local SOCKS5 proxy backed by arti (Tor in
|
||||
* Rust) and — via {@link ArtiProxy.ArtiProxyBuilder#setWrapWebView(boolean)} —
|
||||
* installs an Android {@code ProxyController} override so that <em>all</em>
|
||||
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
|
||||
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
|
||||
*
|
||||
* <p>Activation is "apply on relaunch": the enabled flag is persisted to
|
||||
* {@link SharedPreferences} by {@link TorPlugin} and read here at startup from
|
||||
* {@link MainActivity}. arti is started <em>before</em> the WebView loads so
|
||||
* there is no pre-bootstrap leak window.
|
||||
*
|
||||
* <p>Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
|
||||
* yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
|
||||
* for a future censorship-resistance layer.
|
||||
*/
|
||||
public class TorController {
|
||||
|
||||
private static final String TAG = "TorController";
|
||||
|
||||
/** Local SOCKS5 port arti listens on (arti's own default). */
|
||||
public static final int SOCKS_PORT = 9150;
|
||||
|
||||
static final String PREFS_NAME = "tor_config";
|
||||
static final String KEY_ENABLED = "enabled";
|
||||
|
||||
/** Endpoint used to confirm a working Tor circuit (small JSON response). */
|
||||
private static final String PROBE_URL = "https://check.torproject.org/api/ip";
|
||||
// Re-verify continuously (gently) so the status reflects current reality.
|
||||
private static final long PROBE_INTERVAL_SECONDS = 10;
|
||||
/** After this long without a successful probe, surface a soft "failed". */
|
||||
private static final long SOFT_TIMEOUT_SECONDS = 120;
|
||||
|
||||
// Status values mirrored to JS (see src/lib/tor.ts TorStatus).
|
||||
public static final String STATUS_DISABLED = "disabled";
|
||||
public static final String STATUS_CONNECTING = "connecting";
|
||||
public static final String STATUS_CONNECTED = "connected";
|
||||
public static final String STATUS_FAILED = "failed";
|
||||
|
||||
/** Receives status changes so the Capacitor plugin can forward them to JS. */
|
||||
public interface StatusListener {
|
||||
void onTorStatus(String status, int bootstrapPercent, @Nullable String error, @Nullable String exitIp);
|
||||
}
|
||||
|
||||
private static volatile TorController instance;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
|
||||
private ArtiProxy artiProxy;
|
||||
private ScheduledExecutorService scheduler;
|
||||
|
||||
private volatile String status = STATUS_DISABLED;
|
||||
private volatile int bootstrapPercent = 0;
|
||||
@Nullable private volatile String error = null;
|
||||
/** Tor exit-node IP from the last successful check (for verification UI). */
|
||||
@Nullable private volatile String exitIp = null;
|
||||
/** Consecutive failed probes; used to debounce CONNECTED -> reconnecting. */
|
||||
private int consecutiveFailures = 0;
|
||||
@Nullable private volatile StatusListener listener;
|
||||
private volatile long startedAtMs = 0;
|
||||
|
||||
private TorController() {}
|
||||
|
||||
public static TorController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (TorController.class) {
|
||||
if (instance == null) {
|
||||
instance = new TorController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Whether Tor is enabled in persisted preferences (read at next launch). */
|
||||
public static boolean isEnabled(Context context) {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return prefs.getBoolean(KEY_ENABLED, false);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Takes effect on the next app launch. */
|
||||
public static void setEnabled(Context context, boolean enabled) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_ENABLED, enabled)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void setListener(@Nullable StatusListener listener) {
|
||||
this.listener = listener;
|
||||
// Replay the current status so a freshly-attached listener is in sync.
|
||||
if (listener != null) {
|
||||
listener.onTorStatus(status, bootstrapPercent, error, exitIp);
|
||||
}
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public int getBootstrapPercent() {
|
||||
return bootstrapPercent;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getExitIp() {
|
||||
return exitIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start arti and install the WebView proxy override. Idempotent: a second
|
||||
* call while already running is a no-op. Heavy work runs off the caller's
|
||||
* thread so this is safe to invoke from {@code MainActivity.onCreate}.
|
||||
*/
|
||||
public void start(Context context) {
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
final Context appContext = context.getApplicationContext();
|
||||
exitIp = null;
|
||||
consecutiveFailures = 0;
|
||||
// Install the fail-closed WebView proxy override synchronously, BEFORE
|
||||
// the WebView loads (start() is called from MainActivity.onCreate ahead
|
||||
// of super.onCreate). With no direct fallback, any request that arti
|
||||
// can't carry fails instead of leaking out directly — even during the
|
||||
// bootstrap window when arti isn't connected yet.
|
||||
applyWebViewProxy();
|
||||
updateStatus(STATUS_CONNECTING, 0, null);
|
||||
startedAtMs = System.currentTimeMillis();
|
||||
|
||||
Thread t = new Thread(() -> {
|
||||
try {
|
||||
synchronized (lock) {
|
||||
// NB: we do NOT use setWrapWebView(true) — arti's helper
|
||||
// appends a DIRECT fallback (fail-open). We set our own
|
||||
// fail-closed override in applyWebViewProxy() instead.
|
||||
artiProxy = ArtiProxy.Builder(appContext)
|
||||
.setSocksPort(SOCKS_PORT)
|
||||
.setLogListener(this::onArtiLog)
|
||||
.build();
|
||||
artiProxy.start();
|
||||
}
|
||||
Log.d(TAG, "arti started on socks://127.0.0.1:" + SOCKS_PORT);
|
||||
beginConnectivityProbe();
|
||||
} catch (Throwable e) {
|
||||
Log.e(TAG, "Failed to start arti", e);
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent, String.valueOf(e.getMessage()));
|
||||
}
|
||||
}, "arti-start");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
/** Stop arti and route the WebView back to a direct connection. Safe to
|
||||
* call live (toggle off) — clears the SOCKS proxy override so traffic
|
||||
* doesn't get stranded on the now-stopped proxy. */
|
||||
public void stop() {
|
||||
// Remove the WebView SOCKS override first so new requests go direct.
|
||||
clearWebViewProxy();
|
||||
synchronized (lock) {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
if (artiProxy != null) {
|
||||
try {
|
||||
artiProxy.stop();
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error stopping arti", e);
|
||||
}
|
||||
artiProxy = null;
|
||||
}
|
||||
}
|
||||
started.set(false);
|
||||
exitIp = null;
|
||||
updateStatus(STATUS_DISABLED, 0, null);
|
||||
}
|
||||
|
||||
/** Re-run the connectivity probe (used by a "Retry" action in the gate). */
|
||||
public void retry() {
|
||||
if (!started.get()) {
|
||||
return;
|
||||
}
|
||||
consecutiveFailures = 0;
|
||||
startedAtMs = System.currentTimeMillis();
|
||||
if (!STATUS_CONNECTED.equals(status)) {
|
||||
updateStatus(STATUS_CONNECTING, bootstrapPercent, null);
|
||||
}
|
||||
beginConnectivityProbe();
|
||||
}
|
||||
|
||||
// --- internals -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Route the WebView through arti's SOCKS proxy, FAIL-CLOSED. There is no
|
||||
* {@code addDirect()} fallback, so when Tor can't carry a request it fails
|
||||
* rather than leaking to a direct connection. localhost is bypassed (it's
|
||||
* the local Capacitor asset server, never remote traffic).
|
||||
*/
|
||||
private void applyWebViewProxy() {
|
||||
try {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
ProxyConfig config = new ProxyConfig.Builder()
|
||||
.addProxyRule("socks://127.0.0.1:" + SOCKS_PORT)
|
||||
// No addDirect() — fail closed.
|
||||
.addBypassRule("localhost")
|
||||
.addBypassRule("127.0.0.1")
|
||||
.build();
|
||||
ProxyController.getInstance().setProxyOverride(config, Runnable::run, () -> {});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error applying WebView proxy override", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the app-wide WebView SOCKS proxy override so the WebView reverts
|
||||
* to a direct connection. */
|
||||
private void clearWebViewProxy() {
|
||||
try {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error clearing WebView proxy override", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern PERCENT = Pattern.compile("(\\d{1,3})\\s*%");
|
||||
|
||||
private void onArtiLog(String line) {
|
||||
if (line == null) return;
|
||||
Log.d("artilog", line);
|
||||
// Best-effort bootstrap progress for the UI. arti's log format isn't a
|
||||
// stable API, so the connectivity probe (below) remains authoritative
|
||||
// for the definitive "connected" signal.
|
||||
Matcher m = PERCENT.matcher(line);
|
||||
if (m.find()) {
|
||||
try {
|
||||
int pct = Integer.parseInt(m.group(1));
|
||||
if (pct >= 0 && pct <= 100 && pct >= bootstrapPercent
|
||||
&& !STATUS_CONNECTED.equals(status)) {
|
||||
updateStatus(STATUS_CONNECTING, pct, null);
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void beginConnectivityProbe() {
|
||||
synchronized (lock) {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread th = new Thread(r, "tor-probe");
|
||||
th.setDaemon(true);
|
||||
return th;
|
||||
});
|
||||
final ScheduledExecutorService s = scheduler;
|
||||
final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", SOCKS_PORT)))
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
// Probe continuously (no shutdown on success). check.torproject.org
|
||||
// reports whether the request actually exited via Tor, so we only
|
||||
// report CONNECTED when IsTor is true — and we keep re-verifying so a
|
||||
// dropped circuit downgrades the status instead of lying.
|
||||
s.scheduleWithFixedDelay(() -> {
|
||||
Request req = new Request.Builder()
|
||||
.url(PROBE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.build();
|
||||
try (Response resp = client.newCall(req).execute()) {
|
||||
String body = resp.body() != null ? resp.body().string() : "";
|
||||
boolean isTor = false;
|
||||
String ip = null;
|
||||
try {
|
||||
JSONObject json = new JSONObject(body);
|
||||
isTor = json.optBoolean("IsTor", false);
|
||||
ip = json.has("IP") ? json.optString("IP", null) : null;
|
||||
} catch (JSONException ignored) {
|
||||
// Non-JSON response — treat as not-via-Tor below.
|
||||
}
|
||||
|
||||
if (resp.isSuccessful() && isTor) {
|
||||
consecutiveFailures = 0;
|
||||
exitIp = ip;
|
||||
updateStatus(STATUS_CONNECTED, 100, null);
|
||||
} else if (resp.isSuccessful()) {
|
||||
// Reached the internet but NOT through Tor — a leak/bypass.
|
||||
// This should not happen with the SOCKS proxy, but report
|
||||
// it honestly rather than claiming a Tor connection.
|
||||
consecutiveFailures = 0;
|
||||
exitIp = ip;
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent,
|
||||
"Connected to the internet, but not through Tor.");
|
||||
} else {
|
||||
handleProbeFailure();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
handleProbeFailure();
|
||||
}
|
||||
}, 0, PROBE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/** A probe couldn't reach Tor. Debounce CONNECTED, surface FAILED after the
|
||||
* soft timeout while still connecting. */
|
||||
private void handleProbeFailure() {
|
||||
consecutiveFailures++;
|
||||
if (STATUS_CONNECTED.equals(status)) {
|
||||
// Tolerate a couple of transient blips before downgrading.
|
||||
if (consecutiveFailures >= 3) {
|
||||
exitIp = null;
|
||||
updateStatus(STATUS_CONNECTING, bootstrapPercent,
|
||||
"Lost the Tor circuit; reconnecting…");
|
||||
}
|
||||
return;
|
||||
}
|
||||
long elapsed = (System.currentTimeMillis() - startedAtMs) / 1000;
|
||||
if (elapsed >= SOFT_TIMEOUT_SECONDS && !STATUS_FAILED.equals(status)) {
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent,
|
||||
"Couldn't reach the Tor network. Your network may be blocking Tor.");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(String newStatus, int percent, @Nullable String err) {
|
||||
this.status = newStatus;
|
||||
this.bootstrapPercent = percent;
|
||||
this.error = err;
|
||||
StatusListener l = this.listener;
|
||||
if (l != null) {
|
||||
l.onTorStatus(newStatus, percent, err, exitIp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package spot.agora.app;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/**
|
||||
* Capacitor bridge for the Tor (arti) mode.
|
||||
*
|
||||
* <p>Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
|
||||
* state, native owns the work. The enabled flag is persisted only — arti is
|
||||
* actually started at launch from {@link MainActivity} (apply on relaunch).
|
||||
* Live bootstrap status is pushed to JS via the {@code torStatus} event.
|
||||
*
|
||||
* <p>JS interface: see {@code src/lib/tor.ts}.
|
||||
*/
|
||||
@CapacitorPlugin(name = "Tor")
|
||||
public class TorPlugin extends Plugin {
|
||||
|
||||
private static final String EVENT_STATUS = "torStatus";
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
// Forward native status changes to JS listeners. Attaching also replays
|
||||
// the current status, keeping a newly-mounted JS gate in sync.
|
||||
TorController.getInstance().setListener((status, bootstrapPercent, error, exitIp) -> {
|
||||
JSObject data = new JSObject();
|
||||
data.put("status", status);
|
||||
data.put("bootstrapPercent", bootstrapPercent);
|
||||
data.put("error", error);
|
||||
data.put("exitIp", exitIp);
|
||||
notifyListeners(EVENT_STATUS, data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether Tor is enabled in persisted preferences. */
|
||||
@PluginMethod
|
||||
public void isEnabled(PluginCall call) {
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("enabled", TorController.isEnabled(getContext()));
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Applied on the next app launch. */
|
||||
@PluginMethod
|
||||
public void setEnabled(PluginCall call) {
|
||||
Boolean enabled = call.getBoolean("enabled");
|
||||
if (enabled == null) {
|
||||
call.reject("Missing 'enabled' boolean");
|
||||
return;
|
||||
}
|
||||
TorController.setEnabled(getContext(), enabled);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Start arti now (live activation). Also persists enabled=true so it
|
||||
* auto-starts on the next cold launch. */
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
TorController.setEnabled(getContext(), true);
|
||||
TorController.getInstance().start(getContext());
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Stop arti now (live deactivation) and clear the WebView proxy. Also
|
||||
* persists enabled=false. */
|
||||
@PluginMethod
|
||||
public void stop(PluginCall call) {
|
||||
TorController.setEnabled(getContext(), false);
|
||||
TorController.getInstance().stop();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Current connection status (synchronous snapshot). */
|
||||
@PluginMethod
|
||||
public void getStatus(PluginCall call) {
|
||||
TorController controller = TorController.getInstance();
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("enabled", TorController.isEnabled(getContext()));
|
||||
ret.put("status", controller.getStatus());
|
||||
ret.put("bootstrapPercent", controller.getBootstrapPercent());
|
||||
ret.put("error", controller.getError());
|
||||
ret.put("exitIp", controller.getExitIp());
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
/** Re-run the connectivity probe (for a "Retry" action in the gate). */
|
||||
@PluginMethod
|
||||
public void retry(PluginCall call) {
|
||||
TorController.getInstance().retry();
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Guardian Project's experimental Maven repo, hosting the prebuilt
|
||||
// org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
|
||||
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-2
@@ -15,7 +15,9 @@ import { SentryProvider } from "@/components/SentryProvider";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import { useTor } from "@/hooks/useTor";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
@@ -147,6 +149,7 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: 'https://wsrv.nl',
|
||||
lowBandwidthMode: false,
|
||||
torEnabled: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
esploraApis: [
|
||||
'https://mempool.emzy.de/api',
|
||||
@@ -194,6 +197,24 @@ const defaultConfig: AppConfig = {
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps NostrProvider with a key that changes when Tor routing changes, so the
|
||||
* relay layer remounts: existing connections close and reopen under the new
|
||||
* routing (direct ⇄ fail-closed Tor), and reconnect immediately once Tor is up
|
||||
* rather than waiting out the relay reconnect backoff. No-op off Android (the
|
||||
* key is always "direct").
|
||||
*/
|
||||
function RelayProvider({ children }: { children: React.ReactNode }) {
|
||||
const { config } = useAppContext();
|
||||
const { status } = useTor();
|
||||
const key = !config.torEnabled
|
||||
? "direct"
|
||||
: status === "connected"
|
||||
? "tor-connected"
|
||||
: "tor-pending";
|
||||
return <NostrProvider key={key}>{children}</NostrProvider>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
@@ -205,7 +226,7 @@ export function App() {
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<RelayProvider>
|
||||
<NostrSync />
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
@@ -221,7 +242,7 @@ export function App() {
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</RelayProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
</PlausibleProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
|
||||
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
|
||||
import { TorStatusBanner } from "./components/TorStatusBanner";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -139,6 +140,9 @@ export function AppRouter() {
|
||||
<ScrollToTop />
|
||||
<AudioNavigationGuard />
|
||||
<MinimizedAudioBar />
|
||||
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
|
||||
renders a <Link> to the Tor settings, which needs Router context. */}
|
||||
<TorStatusBanner />
|
||||
<OnboardingGate>
|
||||
<Routes>
|
||||
{/* Narrow layout — `max-w-3xl` center column. The default for
|
||||
|
||||
@@ -297,7 +297,15 @@ function getProfileMenuItems({
|
||||
userPubkey?: string;
|
||||
showDashboard: boolean;
|
||||
}): MobileLinkItem[] {
|
||||
if (!userPubkey) return [];
|
||||
// Logged-out users still get Settings (appearance, language, network, etc.)
|
||||
// and About in the menu — the account-specific items are added below.
|
||||
if (!userPubkey) {
|
||||
return [
|
||||
{ labelKey: 'nav.search', to: '/search', icon: Search },
|
||||
{ labelKey: 'nav.settings', to: '/settings', icon: Settings },
|
||||
{ labelKey: 'nav.about', to: '/about', icon: Info },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ShieldAlert } from 'lucide-react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTor } from '@/hooks/useTor';
|
||||
import { retryTor } from '@/lib/tor';
|
||||
|
||||
/**
|
||||
* Slim, non-blocking, app-wide banner shown while Tor is enabled but not yet
|
||||
* connected (Android only).
|
||||
*
|
||||
* Routing is fail-closed, so external content can't load — and can't leak —
|
||||
* until Tor connects. This tells the user that wherever they are in the app
|
||||
* (so switching away from Settings still surfaces the state). It replaces the
|
||||
* old full-screen gate.
|
||||
*/
|
||||
export function TorStatusBanner() {
|
||||
const { config } = useAppContext();
|
||||
const { supported, status, bootstrapPercent, error } = useTor();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!supported || !config.torEnabled || status === 'connected') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failed = status === 'failed';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed inset-x-0 bottom-0 z-50 safe-area-bottom border-t px-4 py-2 ${
|
||||
failed
|
||||
? 'border-destructive bg-destructive text-destructive-foreground'
|
||||
: 'border-amber-600 bg-amber-500 text-black'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl items-center gap-3 text-xs">
|
||||
{failed ? (
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span className="flex-1 leading-snug">
|
||||
{failed
|
||||
? error || t('tor.banner.failed')
|
||||
: t('tor.banner.connecting') +
|
||||
(bootstrapPercent > 0 ? ` (${bootstrapPercent}%)` : '')}
|
||||
</span>
|
||||
{failed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryTor()}
|
||||
className="shrink-0 font-semibold underline underline-offset-2"
|
||||
>
|
||||
{t('tor.banner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TorStatusBanner;
|
||||
@@ -288,6 +288,15 @@ export interface AppConfig {
|
||||
* Default: false.
|
||||
*/
|
||||
lowBandwidthMode: boolean;
|
||||
/**
|
||||
* Route all app traffic through the Tor network (arti). **Android only** —
|
||||
* ignored on web and iOS. The actual proxy is installed natively at app
|
||||
* startup, so changes take effect on the next launch (see `src/lib/tor.ts`
|
||||
* and the native `TorController`).
|
||||
*
|
||||
* Default: false.
|
||||
*/
|
||||
torEnabled: boolean;
|
||||
/** Hex pubkey of the curator whose follow list defines the curated feed. */
|
||||
curatorPubkey?: string;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PluginListenerHandle } from '@capacitor/core';
|
||||
import {
|
||||
Tor,
|
||||
isTorSupported,
|
||||
getTorStatus,
|
||||
startTor,
|
||||
stopTor,
|
||||
type TorStatus,
|
||||
} from '@/lib/tor';
|
||||
|
||||
export interface UseTor {
|
||||
/** Whether Tor mode is available on this platform (Android only). */
|
||||
supported: boolean;
|
||||
/** Whether Tor is enabled in native preferences. */
|
||||
enabled: boolean;
|
||||
/** False until the first native status has been read (used to avoid a UI flash). */
|
||||
loaded: boolean;
|
||||
status: TorStatus;
|
||||
bootstrapPercent: number;
|
||||
error: string | null;
|
||||
/** Tor exit-node IP from the last successful check, when connected. */
|
||||
exitIp: string | null;
|
||||
/** Persist the enabled flag natively. Takes effect on next app launch. */
|
||||
setEnabled: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to native Tor (arti) status and exposes the enable toggle.
|
||||
* Safe to call on any platform — it simply reports `supported: false` and a
|
||||
* `disabled` status off Android.
|
||||
*/
|
||||
export function useTor(): UseTor {
|
||||
const supported = isTorSupported();
|
||||
const [enabled, setEnabledState] = useState(false);
|
||||
const [loaded, setLoaded] = useState(!isTorSupported());
|
||||
const [status, setStatus] = useState<TorStatus>('disabled');
|
||||
const [bootstrapPercent, setBootstrapPercent] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exitIp, setExitIp] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
|
||||
let active = true;
|
||||
let handle: PluginListenerHandle | undefined;
|
||||
|
||||
getTorStatus().then((s) => {
|
||||
if (!active) return;
|
||||
if (s) {
|
||||
setEnabledState(s.enabled);
|
||||
setStatus(s.status);
|
||||
setBootstrapPercent(s.bootstrapPercent);
|
||||
setError(s.error);
|
||||
setExitIp(s.exitIp ?? null);
|
||||
}
|
||||
setLoaded(true);
|
||||
});
|
||||
|
||||
Tor.addListener('torStatus', (e) => {
|
||||
setStatus(e.status);
|
||||
setBootstrapPercent(e.bootstrapPercent);
|
||||
setError(e.error);
|
||||
setExitIp(e.exitIp ?? null);
|
||||
}).then((h) => {
|
||||
if (active) {
|
||||
handle = h;
|
||||
} else {
|
||||
h.remove();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
handle?.remove();
|
||||
};
|
||||
}, [supported]);
|
||||
|
||||
// Live activation: starting/stopping arti also persists the enabled flag
|
||||
// natively, so it auto-starts again on the next cold launch.
|
||||
const setEnabled = async (next: boolean) => {
|
||||
if (next) {
|
||||
await startTor();
|
||||
} else {
|
||||
await stopTor();
|
||||
}
|
||||
setEnabledState(next);
|
||||
};
|
||||
|
||||
return { supported, enabled, loaded, status, bootstrapPercent, error, exitIp, setEnabled };
|
||||
}
|
||||
@@ -150,6 +150,7 @@ export const AppConfigSchema = z.object({
|
||||
imageQuality: z.enum(['compressed', 'original']),
|
||||
imageProxy: z.string(),
|
||||
lowBandwidthMode: z.boolean(),
|
||||
torEnabled: z.boolean(),
|
||||
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
|
||||
/**
|
||||
* Ordered list of Esplora REST roots tried in failover order. Accepts the
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
/** Native Tor connection status (mirrors the values in TorController.java). */
|
||||
export type TorStatus = 'disabled' | 'connecting' | 'connected' | 'failed';
|
||||
|
||||
export interface TorStatusEvent {
|
||||
status: TorStatus;
|
||||
/** Best-effort bootstrap progress (0–100) parsed from arti logs. */
|
||||
bootstrapPercent: number;
|
||||
error: string | null;
|
||||
/** Tor exit-node IP from the last successful check (verification readout). */
|
||||
exitIp?: string | null;
|
||||
}
|
||||
|
||||
export interface TorPlugin {
|
||||
/** Whether Tor is enabled in persisted native preferences. */
|
||||
isEnabled(): Promise<{ enabled: boolean }>;
|
||||
/** Persist the enabled flag. Applied on the next app launch. */
|
||||
setEnabled(options: { enabled: boolean }): Promise<void>;
|
||||
/** Start arti now (live) and persist enabled=true. */
|
||||
start(): Promise<void>;
|
||||
/** Stop arti now (live), clear the WebView proxy, and persist enabled=false. */
|
||||
stop(): Promise<void>;
|
||||
/** Synchronous snapshot of the current status. */
|
||||
getStatus(): Promise<{ enabled: boolean } & TorStatusEvent>;
|
||||
/** Re-run the connectivity probe (used by the gate's "Retry"). */
|
||||
retry(): Promise<void>;
|
||||
addListener(
|
||||
eventName: 'torStatus',
|
||||
listener: (event: TorStatusEvent) => void,
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The native Tor (arti) plugin. Implemented on Android only — see
|
||||
* `android/app/src/main/java/spot/agora/app/TorPlugin.java`. On web/iOS the
|
||||
* registered proxy exists but its methods reject; always guard with
|
||||
* {@link isTorSupported} (the helpers below do).
|
||||
*/
|
||||
export const Tor = registerPlugin<TorPlugin>('Tor');
|
||||
|
||||
/** Tor mode is only available on Android. */
|
||||
export function isTorSupported(): boolean {
|
||||
return Capacitor.getPlatform() === 'android';
|
||||
}
|
||||
|
||||
/** Persist the Tor enabled flag natively (no-op on unsupported platforms). */
|
||||
export async function setTorEnabled(enabled: boolean): Promise<void> {
|
||||
if (!isTorSupported()) return;
|
||||
try {
|
||||
await Tor.setEnabled({ enabled });
|
||||
} catch {
|
||||
// Native plugin unavailable (e.g. older build) — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current native status, or `null` when Tor isn't supported. */
|
||||
export async function getTorStatus(): Promise<({ enabled: boolean } & TorStatusEvent) | null> {
|
||||
if (!isTorSupported()) return null;
|
||||
try {
|
||||
return await Tor.getStatus();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask arti to re-check connectivity (no-op on unsupported platforms). */
|
||||
export async function retryTor(): Promise<void> {
|
||||
if (!isTorSupported()) return;
|
||||
try {
|
||||
await Tor.retry();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Start arti now (live activation). No-op on unsupported platforms. */
|
||||
export async function startTor(): Promise<void> {
|
||||
if (!isTorSupported()) return;
|
||||
try {
|
||||
await Tor.start();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop arti now (live deactivation). No-op on unsupported platforms. */
|
||||
export async function stopTor(): Promise<void> {
|
||||
if (!isTorSupported()) return;
|
||||
try {
|
||||
await Tor.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "متوقف",
|
||||
"connecting": "جارٍ الاتصال…",
|
||||
"connected": "متصل",
|
||||
"failed": "فشل الاتصال"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "جارٍ الاتصال بـ Tor — لن يتم تحميل المحتوى حتى يصبح جاهزاً.",
|
||||
"failed": "تعذّر الاتصال بـ Tor — المحتوى محجوب حتى يتم الاتصال.",
|
||||
"retry": "حاول مرة أخرى"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "جارٍ التحميل…",
|
||||
"error": "خطأ",
|
||||
@@ -972,7 +985,14 @@
|
||||
"title": "متقدم",
|
||||
"subtitle": "اتصالات المحفظة وتكوين النظام وخيارات أخرى للمستخدمين المتقدمين.",
|
||||
"intro": "اتصالات المحفظة وتكوين النظام وخيارات متقدمة أخرى.",
|
||||
"wallet": "المحفظة"
|
||||
"wallet": "المحفظة",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "توجيه حركة المرور عبر Tor",
|
||||
"torToggleDesc": "يتصل بالمرحّلات ويحمّل الوسائط عبر شبكة Tor لحماية أقوى للخصوصية. توقّع أداءً أبطأ وتأخيراً قصيراً ريثما يتصل Tor.",
|
||||
"torApplyNote": "يؤدي تفعيل هذا الخيار إلى توجيه كل حركة المرور عبر Tor. أثناء الاتصال، لن يتم تحميل المحتوى الآخر (يُعرض بانر يوضّح الحالة) — ولا يُرسَل أي شيء خارج Tor. يؤدي إيقافه إلى العودة إلى الاتصال المباشر.",
|
||||
"torStatusLabel": "الحالة:",
|
||||
"torExitIp": "عنوان IP للخروج من Tor:",
|
||||
"torCheckAgain": "التحقق مجدداً"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "المحفظة",
|
||||
@@ -2902,4 +2922,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Off",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"failed": "Connection failed"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Connecting to Tor — content won't load until it's ready.",
|
||||
"failed": "Couldn't connect to Tor — content is blocked until it connects.",
|
||||
"retry": "Try again"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"error": "Error",
|
||||
@@ -1435,7 +1448,14 @@
|
||||
"title": "Advanced",
|
||||
"subtitle": "Wallet connections, system configuration, and other advanced options for power users.",
|
||||
"intro": "Wallet connections, system configuration, and other advanced options.",
|
||||
"wallet": "Wallet"
|
||||
"wallet": "Wallet",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Route traffic through Tor",
|
||||
"torToggleDesc": "Connects to relays and loads media over the Tor network for stronger privacy. Expect slower performance and a short delay while Tor connects.",
|
||||
"torApplyNote": "Turning this on routes all traffic through Tor. While it connects, other content won't load (a banner shows the status) — nothing is sent outside Tor. Turning it off switches back to a direct connection.",
|
||||
"torStatusLabel": "Status:",
|
||||
"torExitIp": "Tor exit IP:",
|
||||
"torCheckAgain": "Check again"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Wallet",
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Desactivado",
|
||||
"connecting": "Conectando…",
|
||||
"connected": "Conectado",
|
||||
"failed": "Error de conexión"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Conectando a Tor — el contenido no cargará hasta que esté listo.",
|
||||
"failed": "No se pudo conectar a Tor — el contenido está bloqueado hasta que se conecte.",
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Cargando…",
|
||||
"error": "Error",
|
||||
@@ -982,7 +995,14 @@
|
||||
"title": "Avanzado",
|
||||
"subtitle": "Conexiones de cartera, configuración del sistema y otras opciones para usuarios avanzados.",
|
||||
"intro": "Conexiones de cartera, configuración del sistema y otras opciones avanzadas.",
|
||||
"wallet": "Cartera"
|
||||
"wallet": "Cartera",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Enrutar el tráfico a través de Tor",
|
||||
"torToggleDesc": "Se conecta a relés y carga los medios a través de la red Tor para mayor privacidad. El rendimiento será más lento y habrá un breve retraso mientras Tor se conecta.",
|
||||
"torApplyNote": "Al activar esta opción, todo el tráfico se enruta a través de Tor. Mientras se conecta, el resto del contenido no cargará (un banner muestra el estado) — nada se envía fuera de Tor. Al desactivarla, se vuelve a una conexión directa.",
|
||||
"torStatusLabel": "Estado:",
|
||||
"torExitIp": "IP de salida de Tor:",
|
||||
"torCheckAgain": "Comprobar de nuevo"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Cartera",
|
||||
@@ -2921,4 +2941,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "خاموش",
|
||||
"connecting": "در حال اتصال…",
|
||||
"connected": "متصل",
|
||||
"failed": "اتصال ناموفق بود"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "در حال اتصال به Tor — تا زمانی که آماده نشود، محتوا بارگذاری نمیشود.",
|
||||
"failed": "اتصال به Tor برقرار نشد — تا زمان اتصال، محتوا مسدود است.",
|
||||
"retry": "دوباره تلاش کنید"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "در حال بارگذاری…",
|
||||
"error": "خطا",
|
||||
@@ -982,7 +995,14 @@
|
||||
"title": "پیشرفته",
|
||||
"subtitle": "اتصالات کیف پول، پیکربندی سیستم و سایر گزینههای پیشرفته برای کاربران حرفهای.",
|
||||
"intro": "اتصالات کیف پول، پیکربندی سیستم و سایر گزینههای پیشرفته.",
|
||||
"wallet": "کیف پول"
|
||||
"wallet": "کیف پول",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "مسیردهی ترافیک از طریق Tor",
|
||||
"torToggleDesc": "به رلهها متصل میشود و رسانه را از طریق شبکهٔ Tor بارگذاری میکند تا حریم خصوصی بیشتری داشته باشید. انتظار کارایی کندتر و تأخیر کوتاهی هنگام اتصال Tor را داشته باشید.",
|
||||
"torApplyNote": "روشن کردن این گزینه تمام ترافیک را از طریق Tor مسیر میدهد. در زمان اتصال، سایر محتواها بارگذاری نمیشوند (یک بنر وضعیت را نشان میدهد) — هیچ دادهای خارج از Tor ارسال نمیشود. خاموش کردن آن به اتصال مستقیم بازمیگردد.",
|
||||
"torStatusLabel": "وضعیت:",
|
||||
"torExitIp": "IP خروجی Tor:",
|
||||
"torCheckAgain": "دوباره بررسی کنید"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "کیف پول",
|
||||
@@ -2912,4 +2932,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Désactivé",
|
||||
"connecting": "Connexion…",
|
||||
"connected": "Connecté",
|
||||
"failed": "Échec de la connexion"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Connexion à Tor en cours — le contenu ne se chargera pas avant qu'il soit prêt.",
|
||||
"failed": "Impossible de se connecter à Tor — le contenu est bloqué jusqu'à ce qu'il se connecte.",
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Chargement…",
|
||||
"error": "Erreur",
|
||||
@@ -1421,7 +1434,14 @@
|
||||
"title": "Avancé",
|
||||
"subtitle": "Connexions de portefeuille, configuration système et autres options avancées pour les utilisateurs expérimentés.",
|
||||
"intro": "Connexions de portefeuille, configuration système et autres options avancées.",
|
||||
"wallet": "Portefeuille"
|
||||
"wallet": "Portefeuille",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Acheminer le trafic via Tor",
|
||||
"torToggleDesc": "Se connecte à des relais et charge les médias via le réseau Tor pour une meilleure confidentialité. Attendez-vous à des performances réduites et un court délai le temps que Tor se connecte.",
|
||||
"torApplyNote": "Activer cette option achemine tout le trafic via Tor. Pendant la connexion, les autres contenus ne se chargeront pas (une bannière indique l'état) — rien n'est envoyé en dehors de Tor. La désactiver rétablit une connexion directe.",
|
||||
"torStatusLabel": "État :",
|
||||
"torExitIp": "IP de sortie Tor :",
|
||||
"torCheckAgain": "Vérifier à nouveau"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Portefeuille",
|
||||
@@ -2918,4 +2938,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "बंद",
|
||||
"connecting": "जुड़ा जा रहा है…",
|
||||
"connected": "जुड़ गया",
|
||||
"failed": "कनेक्शन विफल"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Tor से जुड़ा जा रहा है — तैयार होने तक कंटेंट लोड नहीं होगा।",
|
||||
"failed": "Tor से नहीं जुड़ सके — जुड़ने तक कंटेंट ब्लॉक रहेगा।",
|
||||
"retry": "फिर से कोशिश करें"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "लोड हो रहा है…",
|
||||
"error": "त्रुटि",
|
||||
@@ -1424,7 +1437,14 @@
|
||||
"title": "उन्नत",
|
||||
"subtitle": "वॉलेट कनेक्शन, सिस्टम कॉन्फ़िगरेशन, और पावर यूज़र के लिए अन्य उन्नत विकल्प।",
|
||||
"intro": "वॉलेट कनेक्शन, सिस्टम कॉन्फ़िगरेशन, और अन्य उन्नत विकल्प।",
|
||||
"wallet": "वॉलेट"
|
||||
"wallet": "वॉलेट",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Tor के ज़रिए ट्रैफ़िक भेजें",
|
||||
"torToggleDesc": "Tor नेटवर्क पर रिले से जुड़ता है और मीडिया लोड करता है, जिससे प्राइवेसी मज़बूत होती है। धीमी परफ़ॉर्मेंस और Tor के जुड़ने में थोड़ी देरी की उम्मीद रखें।",
|
||||
"torApplyNote": "इसे चालू करने पर सारा ट्रैफ़िक Tor से होकर जाता है। जुड़ते समय दूसरा कंटेंट लोड नहीं होगा (स्टेटस एक बैनर में दिखता है) — Tor के बाहर कुछ नहीं भेजा जाता। बंद करने पर सीधे कनेक्शन पर वापस आ जाता है।",
|
||||
"torStatusLabel": "स्टेटस:",
|
||||
"torExitIp": "Tor exit IP:",
|
||||
"torCheckAgain": "फिर से जाँचें"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "वॉलेट",
|
||||
@@ -2836,4 +2856,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Mati",
|
||||
"connecting": "Menghubungkan…",
|
||||
"connected": "Terhubung",
|
||||
"failed": "Koneksi gagal"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Menghubungkan ke Tor — konten tidak akan dimuat hingga siap.",
|
||||
"failed": "Tidak dapat terhubung ke Tor — konten diblokir hingga berhasil terhubung.",
|
||||
"retry": "Coba lagi"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Memuat…",
|
||||
"error": "Kesalahan",
|
||||
@@ -1424,7 +1437,14 @@
|
||||
"title": "Lanjutan",
|
||||
"subtitle": "Koneksi dompet, konfigurasi sistem, dan opsi lanjutan lainnya untuk pengguna mahir.",
|
||||
"intro": "Koneksi dompet, konfigurasi sistem, dan opsi lanjutan lainnya.",
|
||||
"wallet": "Dompet"
|
||||
"wallet": "Dompet",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Arahkan lalu lintas melalui Tor",
|
||||
"torToggleDesc": "Terhubung ke relay dan memuat media melalui jaringan Tor untuk privasi yang lebih kuat. Performa lebih lambat dan ada jeda singkat saat Tor terhubung.",
|
||||
"torApplyNote": "Mengaktifkan ini mengarahkan semua lalu lintas melalui Tor. Saat terhubung, konten lain tidak akan dimuat (banner menampilkan status) — tidak ada yang dikirim di luar Tor. Menonaktifkan ini beralih kembali ke koneksi langsung.",
|
||||
"torStatusLabel": "Status:",
|
||||
"torExitIp": "IP keluar Tor:",
|
||||
"torCheckAgain": "Periksa lagi"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Dompet",
|
||||
@@ -2836,4 +2856,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "បិទ",
|
||||
"connecting": "កំពុងភ្ជាប់…",
|
||||
"connected": "បានភ្ជាប់",
|
||||
"failed": "ការភ្ជាប់បានបរាជ័យ"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "កំពុងភ្ជាប់ទៅ Tor — មាតិកានឹងមិនផ្ទុករហូតដល់វារួចរាល់។",
|
||||
"failed": "មិនអាចភ្ជាប់ទៅ Tor បានទេ — មាតិកាត្រូវបានស្ទះរហូតដល់វាភ្ជាប់។",
|
||||
"retry": "សូមព្យាយាមម្តងទៀត"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "កំពុងផ្ទុក…",
|
||||
"error": "កំហុស",
|
||||
@@ -982,7 +995,14 @@
|
||||
"title": "កម្រិតខ្ពស់",
|
||||
"subtitle": "ការតភ្ជាប់កាបូប ការកំណត់ប្រព័ន្ធ និងជម្រើសផ្សេងទៀតសម្រាប់អ្នកប្រើជាន់ខ្ពស់។",
|
||||
"intro": "ការតភ្ជាប់កាបូប ការកំណត់ប្រព័ន្ធ និងជម្រើសកម្រិតខ្ពស់ផ្សេងទៀត។",
|
||||
"wallet": "កាបូប"
|
||||
"wallet": "កាបូប",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "បញ្ជូនចរាចរណ៍តាម Tor",
|
||||
"torToggleDesc": "ភ្ជាប់ទៅ relay និងផ្ទុកមេឌៀតាមបណ្តាញ Tor ដើម្បីការពារភាពឯកជនកាន់តែខ្លាំង។ រំពឹងថានឹងដំណើរការយឺតជាងមុន និងមានការពន្យារពេលបន្តិចខណៈ Tor កំពុងភ្ជាប់។",
|
||||
"torApplyNote": "ការបើកនេះនឹងបញ្ជូនចរាចរណ៍ទាំងអស់តាម Tor។ ខណៈពេលភ្ជាប់ មាតិកាផ្សេងទៀតនឹងមិនផ្ទុក (បដាបង្ហាញស្ថានភាព) — គ្មានអ្វីត្រូវបានផ្ញើក្រៅ Tor ឡើយ។ ការបិទនឹងប្តូរត្រឡប់ទៅការភ្ជាប់ផ្ទាល់វិញ។",
|
||||
"torStatusLabel": "ស្ថានភាព៖",
|
||||
"torExitIp": "IP ចេញរបស់ Tor៖",
|
||||
"torCheckAgain": "ពិនិត្យម្តងទៀត"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "កាបូប",
|
||||
@@ -2912,4 +2932,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "بند",
|
||||
"connecting": "نښلول کېږي…",
|
||||
"connected": "نښلیدلی",
|
||||
"failed": "اتصال ناکام شو"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Tor سره نښلول کېږي — تر چمتو کېدو پورې به مینځپانګه نه بارېږي.",
|
||||
"failed": "Tor سره اتصال ونشو — تر نښلیدو پورې مینځپانګه بنده ده.",
|
||||
"retry": "بیا هڅه وکړئ"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "د بارولو په حال کې…",
|
||||
"error": "تېروتنه",
|
||||
@@ -984,7 +997,14 @@
|
||||
"title": "پرمختللي",
|
||||
"subtitle": "د بټوې اړیکې، د سیسټم تنظیمات، او د پرمختللو کاروونکو لپاره نور انتخابونه.",
|
||||
"intro": "د بټوې اړیکې، د سیسټم تنظیمات، او نور پرمختللي انتخابونه.",
|
||||
"wallet": "بټوه"
|
||||
"wallet": "بټوه",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "ټرافیک د Tor له لارې لیږئ",
|
||||
"torToggleDesc": "د Tor شبکې له لارې ریلې سره نښلي او مېډیا بارو ي، چې قوي محرمیت چمتو کوي. د ورو کار او د Tor د نښلیدو لپاره لنډ ځنډ تمه کړئ.",
|
||||
"torApplyNote": "د دې فعالولو سره ټول ټرافیک د Tor له لارې لیږل کېږي. د نښلیدو پر مهال، نوره مینځپانګه نه بارېږي (یو بنر حالت ښیي) — د Tor بهر هیڅ نه لیږل کیږي. د بندولو سره مستقیم اتصال ته بیرته راګرځي.",
|
||||
"torStatusLabel": "حالت:",
|
||||
"torExitIp": "د Tor د وتلو IP:",
|
||||
"torCheckAgain": "بیا وګورئ"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "بټوه",
|
||||
@@ -2914,4 +2934,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Desligado",
|
||||
"connecting": "Conectando…",
|
||||
"connected": "Conectado",
|
||||
"failed": "Falha na conexão"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Conectando ao Tor — o conteúdo não carregará até que esteja pronto.",
|
||||
"failed": "Não foi possível conectar ao Tor — o conteúdo está bloqueado até que a conexão seja estabelecida.",
|
||||
"retry": "Tentar novamente"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Carregando…",
|
||||
"error": "Erro",
|
||||
@@ -1426,7 +1439,14 @@
|
||||
"title": "Avançado",
|
||||
"subtitle": "Conexões de carteira, configuração de sistema e outras opções avançadas para usuários experientes.",
|
||||
"intro": "Conexões de carteira, configuração de sistema e outras opções avançadas.",
|
||||
"wallet": "Carteira"
|
||||
"wallet": "Carteira",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Rotear tráfego pelo Tor",
|
||||
"torToggleDesc": "Conecta-se a relays e carrega mídia pela rede Tor para maior privacidade. Espere desempenho mais lento e uma breve espera enquanto o Tor se conecta.",
|
||||
"torApplyNote": "Ativar isso roteia todo o tráfego pelo Tor. Enquanto conecta, outros conteúdos não carregarão (uma faixa exibe o status) — nada é enviado fora do Tor. Desativar volta para uma conexão direta.",
|
||||
"torStatusLabel": "Status:",
|
||||
"torExitIp": "IP de saída do Tor:",
|
||||
"torCheckAgain": "Verificar novamente"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Carteira",
|
||||
@@ -2923,4 +2943,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Выключено",
|
||||
"connecting": "Подключение…",
|
||||
"connected": "Подключено",
|
||||
"failed": "Ошибка подключения"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Подключение к Tor — контент не загрузится, пока соединение не установлено.",
|
||||
"failed": "Не удалось подключиться к Tor — контент заблокирован до установления соединения.",
|
||||
"retry": "Попробовать снова"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка…",
|
||||
"error": "Ошибка",
|
||||
@@ -1426,7 +1439,14 @@
|
||||
"title": "Расширенные",
|
||||
"subtitle": "Подключения кошельков, конфигурация системы и другие расширенные опции для опытных пользователей.",
|
||||
"intro": "Подключения кошельков, конфигурация системы и другие расширенные опции.",
|
||||
"wallet": "Кошелёк"
|
||||
"wallet": "Кошелёк",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Направлять трафик через Tor",
|
||||
"torToggleDesc": "Подключается к ретрансляторам и загружает медиа через сеть Tor для повышенной конфиденциальности. Ожидайте более низкую скорость и небольшую задержку при подключении Tor.",
|
||||
"torApplyNote": "Включение этой опции направляет весь трафик через Tor. Пока соединение устанавливается, другой контент не загружается (статус отображается в баннере) — ничего не отправляется за пределы Tor. Отключение переключает обратно на прямое соединение.",
|
||||
"torStatusLabel": "Статус:",
|
||||
"torExitIp": "Выходной IP Tor:",
|
||||
"torCheckAgain": "Проверить снова"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Кошелёк",
|
||||
@@ -2923,4 +2943,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Dzimwa",
|
||||
"connecting": "Kubatanidza…",
|
||||
"connected": "Yakabatanidza",
|
||||
"failed": "Kubatanidza kwakundikana"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Kubatanidza kuTor — zvichakara zvichirodhwa kusvikira zvasvika.",
|
||||
"failed": "Hatina kukwanisa kubatanidza kuTor — zvichakara zvakavharwa kusvikira zvabatanidza.",
|
||||
"retry": "Edza zvakare"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Inotakura…",
|
||||
"error": "Kukanganisa",
|
||||
@@ -984,7 +997,14 @@
|
||||
"title": "Zvepamusoro",
|
||||
"subtitle": "Mibatanidzwa yechikwama, marongero esystem, nezvimwe zvinotenderwa kuvashandisi venyanzvi.",
|
||||
"intro": "Mibatanidzwa yechikwama, marongero esystem, nezvimwe zvepamusoro.",
|
||||
"wallet": "Chikwama"
|
||||
"wallet": "Chikwama",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Fambisa traffic kuburikidza neTor",
|
||||
"torToggleDesc": "Inobatanidza kune marelay uye inorodha media pamusoro peNetwork yeTor kuti uwane kuchengetedzeka kwakasimba. Tarisira kushanda kwakarefu uye kugumira kadiki Tor ichiri kubatanidza.",
|
||||
"torApplyNote": "Kuvhura izvi kunofambisa traffic yose kuburikidza neTor. Ichiri kubatanidza, zvimwe zvichakara hazvizorohwi (bhana inoratidzwa ichiratidza mamiriro) — hapana chinhumwa kunze kweTor. Kudzima kunodzizoresa kubatanidza kwakanga kwakagara.",
|
||||
"torStatusLabel": "Mamiriro:",
|
||||
"torExitIp": "IP yokubuda kweTor:",
|
||||
"torCheckAgain": "Tarisa zvakare"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Chikwama",
|
||||
@@ -2914,4 +2934,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Imezimwa",
|
||||
"connecting": "Inaunganisha…",
|
||||
"connected": "Imeunganishwa",
|
||||
"failed": "Muunganisho umeshindikana"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Inaunganisha kwenye Tor — maudhui hayatapakia hadi itakapokuwa tayari.",
|
||||
"failed": "Haikuweza kuunganisha kwenye Tor — maudhui yamezuiwa hadi itakapoungana.",
|
||||
"retry": "Jaribu tena"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Inapakia…",
|
||||
"error": "Hitilafu",
|
||||
@@ -1423,7 +1436,14 @@
|
||||
"title": "Ya kina",
|
||||
"subtitle": "Muunganisho wa pochi, usanidi wa mfumo, na chaguzi zingine za kina kwa watumiaji wenye uwezo.",
|
||||
"intro": "Muunganisho wa pochi, usanidi wa mfumo, na chaguzi zingine za kina.",
|
||||
"wallet": "Pochi"
|
||||
"wallet": "Pochi",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Peleka trafiki kupitia Tor",
|
||||
"torToggleDesc": "Inaunganisha kwenye relei na kupakia midia kupitia mtandao wa Tor kwa faragha zaidi. Tarajia utendaji polepole na kuchelewa kidogo wakati Tor inaunganisha.",
|
||||
"torApplyNote": "Kuiwasha hupeleka trafiki yote kupitia Tor. Wakati inaunganisha, maudhui mengine hayatapakia (bango linaonyesha hali) — hakuna kinachotumwa nje ya Tor. Kuizima kurudi kwenye muunganisho wa moja kwa moja.",
|
||||
"torStatusLabel": "Hali:",
|
||||
"torExitIp": "IP ya kutoka ya Tor:",
|
||||
"torCheckAgain": "Angalia tena"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Pochi",
|
||||
@@ -2795,4 +2815,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "Kapalı",
|
||||
"connecting": "Bağlanıyor…",
|
||||
"connected": "Bağlı",
|
||||
"failed": "Bağlantı başarısız"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "Tor'a bağlanılıyor — hazır olana kadar içerik yüklenmeyecek.",
|
||||
"failed": "Tor'a bağlanılamadı — bağlantı kurulana kadar içerik engellendi.",
|
||||
"retry": "Tekrar dene"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Yükleniyor…",
|
||||
"error": "Hata",
|
||||
@@ -1425,7 +1438,14 @@
|
||||
"title": "Gelişmiş",
|
||||
"subtitle": "Cüzdan bağlantıları, sistem yapılandırması ve ileri kullanıcılar için diğer gelişmiş seçenekler.",
|
||||
"intro": "Cüzdan bağlantıları, sistem yapılandırması ve diğer gelişmiş seçenekler.",
|
||||
"wallet": "Cüzdan"
|
||||
"wallet": "Cüzdan",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "Trafiği Tor üzerinden yönlendir",
|
||||
"torToggleDesc": "Daha güçlü gizlilik için Tor ağı üzerinden rölelere bağlanır ve medya yükler. Daha yavaş bir performans ve Tor bağlanırken kısa bir bekleme süresi bekleyin.",
|
||||
"torApplyNote": "Bunu açmak tüm trafiği Tor üzerinden yönlendirir. Bağlanırken diğer içerikler yüklenmez (durum bir banner ile gösterilir) — Tor dışına hiçbir şey gönderilmez. Kapatmak doğrudan bağlantıya geri döner.",
|
||||
"torStatusLabel": "Durum:",
|
||||
"torExitIp": "Tor çıkış IP:",
|
||||
"torCheckAgain": "Tekrar kontrol et"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Cüzdan",
|
||||
@@ -2837,4 +2857,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "關閉",
|
||||
"connecting": "連線中…",
|
||||
"connected": "已連線",
|
||||
"failed": "連線失敗"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "正在連線至 Tor — 連線就緒前內容將無法載入。",
|
||||
"failed": "無法連線至 Tor — 連線成功前內容將被封鎖。",
|
||||
"retry": "重試"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中…",
|
||||
"error": "錯誤",
|
||||
@@ -984,7 +997,14 @@
|
||||
"title": "高階",
|
||||
"subtitle": "錢包連線、系統配置和其他面向高階使用者的選項。",
|
||||
"intro": "錢包連線、系統配置和其他高階選項。",
|
||||
"wallet": "錢包"
|
||||
"wallet": "錢包",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "透過 Tor 路由流量",
|
||||
"torToggleDesc": "連線至中繼節點並透過 Tor 網路載入媒體,以獲得更強的隱私保護。預期速度會較慢,且 Tor 連線期間會有短暫延遲。",
|
||||
"torApplyNote": "開啟此功能會將所有流量透過 Tor 路由。連線期間,其他內容將無法載入(橫幅會顯示連線狀態)— 不會有任何資料在 Tor 以外傳送。關閉後將切換回直接連線。",
|
||||
"torStatusLabel": "狀態:",
|
||||
"torExitIp": "Tor 出口 IP:",
|
||||
"torCheckAgain": "重新檢查"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "錢包",
|
||||
@@ -2838,4 +2858,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"tor": {
|
||||
"status": {
|
||||
"disabled": "已关闭",
|
||||
"connecting": "连接中…",
|
||||
"connected": "已连接",
|
||||
"failed": "连接失败"
|
||||
},
|
||||
"banner": {
|
||||
"connecting": "正在连接 Tor — 连接完成前内容无法加载。",
|
||||
"failed": "无法连接到 Tor — 内容已被屏蔽,等待连接成功后恢复。",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中…",
|
||||
"error": "错误",
|
||||
@@ -984,7 +997,14 @@
|
||||
"title": "高级",
|
||||
"subtitle": "钱包连接、系统配置和其他面向高级用户的选项。",
|
||||
"intro": "钱包连接、系统配置和其他高级选项。",
|
||||
"wallet": "钱包"
|
||||
"wallet": "钱包",
|
||||
"torHeading": "Tor",
|
||||
"torToggle": "通过 Tor 路由流量",
|
||||
"torToggleDesc": "连接到中继并通过 Tor 网络加载媒体内容,以获得更强的隐私保护。预计性能会有所下降,且 Tor 连接时会有短暂延迟。",
|
||||
"torApplyNote": "开启后,所有流量将通过 Tor 路由。连接期间,其他内容将无法加载(横幅会显示状态)——不会有任何数据在 Tor 之外发送。关闭后将切换回直连。",
|
||||
"torStatusLabel": "状态:",
|
||||
"torExitIp": "Tor 出口 IP:",
|
||||
"torCheckAgain": "重新检查"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "钱包",
|
||||
@@ -2914,4 +2934,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,20 @@ import { AdvancedSettings } from '@/components/AdvancedSettings';
|
||||
import { WalletSettings } from '@/components/WalletSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTor } from '@/hooks/useTor';
|
||||
import { retryTor } from '@/lib/tor';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function AdvancedSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const tor = useTor();
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
@@ -37,6 +42,73 @@ export function AdvancedSettingsPage() {
|
||||
{t('settings.advanced.intro')}
|
||||
</p>
|
||||
|
||||
{/* Tor (Android only). Lives here rather than under Network so it's
|
||||
reachable without logging in. The proxy is wired up natively at
|
||||
launch, so changes apply on the next app restart. */}
|
||||
{tor.supported && (
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">{t('settings.advanced.torHeading')}</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pt-4 pb-4 px-3 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label htmlFor="tor-enabled" className="text-sm font-medium">
|
||||
{t('settings.advanced.torToggle')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t('settings.advanced.torToggleDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="tor-enabled"
|
||||
checked={config.torEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig((prev) => ({ ...prev, torEnabled: checked }));
|
||||
// Start/stop arti now (live) — no page reload, no modal.
|
||||
// Routing is fail-closed, so while connecting, external
|
||||
// content simply won't load (a bottom banner explains why);
|
||||
// the relay layer remounts to reconnect through Tor once
|
||||
// it's up. On disable, arti stops and the proxy is cleared.
|
||||
tor.setEnabled(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t('settings.advanced.torApplyNote')}
|
||||
</p>
|
||||
{config.torEnabled && (
|
||||
<div className="space-y-2 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
{t('settings.advanced.torStatusLabel')}{' '}
|
||||
<span className={`font-medium ${tor.status === 'failed' ? 'text-destructive' : 'text-foreground'}`}>
|
||||
{t(`tor.status.${tor.status}`)}
|
||||
</span>
|
||||
{tor.status === 'connecting' && tor.bootstrapPercent > 0
|
||||
? ` (${tor.bootstrapPercent}%)`
|
||||
: ''}
|
||||
</p>
|
||||
{tor.status === 'connected' && tor.exitIp && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('settings.advanced.torExitIp')}{' '}
|
||||
<span className="font-mono text-foreground">{tor.exitIp}</span>
|
||||
</p>
|
||||
)}
|
||||
{tor.status === 'failed' && tor.error && (
|
||||
<p className="text-destructive leading-relaxed">{tor.error}</p>
|
||||
)}
|
||||
{(tor.status === 'failed' || tor.status === 'connecting') && (
|
||||
<Button variant="outline" size="sm" onClick={() => retryTor()}>
|
||||
{t('settings.advanced.torCheckAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet collapsible — only when logged in */}
|
||||
{user && (
|
||||
<Collapsible open={walletOpen} onOpenChange={setWalletOpen}>
|
||||
|
||||
@@ -116,6 +116,7 @@ export function TestApp({ children }: TestAppProps) {
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: '',
|
||||
lowBandwidthMode: false,
|
||||
torEnabled: false,
|
||||
esploraApis: ['https://mempool.space/api'],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: '',
|
||||
|
||||
Reference in New Issue
Block a user