Pier Angelo Vendrame pushed to branch tor-browser-115.6.0esr-13.5-1 at The Tor Project / Applications / Tor Browser
Commits: 1edf6cd0 by Pier Angelo Vendrame at 2023-12-20T17:50:17+00:00 fixup! Bug 42247: Android helpers for the TorProvider
Some wiring for TorSettings and TorConnect stuff and fixes to the one I created previously.
- - - - - e9cc4840 by Dan Ballard at 2023-12-20T17:50:17+00:00 fixup! Bug 42247: Android helpers for the TorProvider
Bug 42301: fix and implement loading settings and saving them to TorSettings.sys.mjs from Java
- - - - -
6 changed files:
- mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java - mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorIntegrationAndroid.java - + mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorSettings.java - + mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/Prefs.java - + mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/TorLegacyAndroidSettings.java - toolkit/modules/TorAndroidIntegration.sys.mjs
Changes:
===================================== mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java ===================================== @@ -1008,6 +1008,14 @@ public final class GeckoRuntime implements Parcelable { return mPushController; }
+ /** + * Get the Tor integration controller for this runtime. + */ + @UiThread + public @NonNull TorIntegrationAndroid getTorIntegrationController() { + return mTorIntegration; + } + /** * Appends notes to crash report. *
===================================== mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorIntegrationAndroid.java ===================================== @@ -9,6 +9,10 @@ package org.mozilla.geckoview; import android.content.Context; import android.util.Log;
+import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.io.BufferedReader; import java.io.File; import java.io.IOException; @@ -22,9 +26,9 @@ import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; -import java.util.UUID;
import org.mozilla.gecko.EventDispatcher; import org.mozilla.gecko.GeckoAppShell; @@ -32,13 +36,27 @@ import org.mozilla.gecko.util.BundleEventListener; import org.mozilla.gecko.util.EventCallback; import org.mozilla.gecko.util.GeckoBundle;
-/* package */ class TorIntegrationAndroid implements BundleEventListener { +import org.mozilla.geckoview.androidlegacysettings.TorLegacyAndroidSettings; + +public class TorIntegrationAndroid implements BundleEventListener { private static final String TAG = "TorIntegrationAndroid";
- private static final String TOR_EVENT_START = "GeckoView:Tor:StartTor"; - private static final String TOR_EVENT_STOP = "GeckoView:Tor:StopTor"; - private static final String MEEK_EVENT_START = "GeckoView:Tor:StartMeek"; - private static final String MEEK_EVENT_STOP = "GeckoView:Tor:StopMeek"; + // Events we listen to + private static final String EVENT_TOR_START = "GeckoView:Tor:StartTor"; + private static final String EVENT_TOR_STOP = "GeckoView:Tor:StopTor"; + private static final String EVENT_MEEK_START = "GeckoView:Tor:StartMeek"; + private static final String EVENT_MEEK_STOP = "GeckoView:Tor:StopMeek"; + + // Events we emit + private static final String EVENT_SETTINGS_GET = "GeckoView:Tor:SettingsGet"; + private static final String EVENT_SETTINGS_SET = "GeckoView:Tor:SettingsSet"; + private static final String EVENT_SETTINGS_APPLY = "GeckoView:Tor:SettingsApply"; + private static final String EVENT_SETTINGS_SAVE = "GeckoView:Tor:SettingsSave"; + private static final String EVENT_BOOTSTRAP_BEGIN = "GeckoView:Tor:BootstrapBegin"; + private static final String EVENT_BOOTSTRAP_BEGIN_AUTO = "GeckoView:Tor:BootstrapBeginAuto"; + private static final String EVENT_BOOTSTRAP_CANCEL = "GeckoView:Tor:BootstrapCancel"; + private static final String EVENT_BOOTSTRAP_GET_STATE = "GeckoView:Tor:BootstrapGetState"; + private static final String EVENT_SETTINGS_READY = "GeckoView:Tor:SettingsReady";
private static final String CONTROL_PORT_FILE = "/control-ipc"; private static final String SOCKS_FILE = "/socks-ipc"; @@ -63,7 +81,9 @@ import org.mozilla.gecko.util.GeckoBundle; private final HashMap<Integer, MeekTransport> mMeeks = new HashMap<>(); private int mMeekCounter;
- public TorIntegrationAndroid(Context context) { + private TorSettings mSettings = null; + + /* package */ TorIntegrationAndroid(Context context) { mLibraryDir = context.getApplicationInfo().nativeLibraryDir; mCacheDir = context.getCacheDir().toPath(); mIpcDirectory = mCacheDir + "/tor-private"; @@ -71,7 +91,7 @@ import org.mozilla.gecko.util.GeckoBundle; registerListener(); }
- public synchronized void shutdown() { + /* package */ synchronized void shutdown() { // FIXME: It seems this never gets called if (mTorProcess != null) { mTorProcess.shutdown(); @@ -83,22 +103,36 @@ import org.mozilla.gecko.util.GeckoBundle; EventDispatcher.getInstance() .registerUiThreadListener( this, - TOR_EVENT_START, - MEEK_EVENT_START, - MEEK_EVENT_STOP); + EVENT_TOR_START, + EVENT_MEEK_START, + EVENT_MEEK_STOP, + EVENT_SETTINGS_READY); }
@Override // BundleEventListener public synchronized void handleMessage( final String event, final GeckoBundle message, final EventCallback callback) { - if (TOR_EVENT_START.equals(event)) { + if (EVENT_TOR_START.equals(event)) { startDaemon(message, callback); - } else if (TOR_EVENT_STOP.equals(event)) { + } else if (EVENT_TOR_STOP.equals(event)) { stopDaemon(message, callback); - } else if (MEEK_EVENT_START.equals(event)) { + } else if (EVENT_MEEK_START.equals(event)) { startMeek(message, callback); - } else if (MEEK_EVENT_STOP.equals(event)) { + } else if (EVENT_MEEK_STOP.equals(event)) { stopMeek(message, callback); + } else if (EVENT_SETTINGS_READY.equals(event)) { + loadSettings(message); + } + } + + private void loadSettings(GeckoBundle message) { + if (TorLegacyAndroidSettings.unmigrated()) { + mSettings = TorLegacyAndroidSettings.loadTorSettings(); + setSettings(mSettings); + TorLegacyAndroidSettings.setMigrated(); + } else { + GeckoBundle bundle = message.getBundle("settings"); + mSettings = new TorSettings(bundle); } }
@@ -145,9 +179,9 @@ import org.mozilla.gecko.util.GeckoBundle; }
class TorProcess extends Thread { - private static final String TOR_EVENT_STARTED = "GeckoView:Tor:TorStarted"; - private static final String TOR_EVENT_START_FAILED = "GeckoView:Tor:TorStartFailed"; - private static final String TOR_EVENT_EXITED = "GeckoView:Tor:TorExited"; + private static final String EVENT_TOR_STARTED = "GeckoView:Tor:TorStarted"; + private static final String EVENT_TOR_START_FAILED = "GeckoView:Tor:TorStartFailed"; + private static final String EVENT_TOR_EXITED = "GeckoView:Tor:TorExited"; private final String mHandle; private Process mProcess = null;
@@ -202,14 +236,14 @@ import org.mozilla.gecko.util.GeckoBundle; final GeckoBundle data = new GeckoBundle(2); data.putString("handle", mHandle); data.putString("error", e.getMessage()); - EventDispatcher.getInstance().dispatch(TOR_EVENT_START_FAILED, data); + EventDispatcher.getInstance().dispatch(EVENT_TOR_START_FAILED, data); return; } Log.i(TAG, "Tor process " + mHandle + " started."); { final GeckoBundle data = new GeckoBundle(1); data.putString("handle", mHandle); - EventDispatcher.getInstance().dispatch(TOR_EVENT_STARTED, data); + EventDispatcher.getInstance().dispatch(EVENT_TOR_STARTED, data); } try { BufferedReader reader = new BufferedReader(new InputStreamReader(mProcess.getInputStream())); @@ -232,7 +266,7 @@ import org.mozilla.gecko.util.GeckoBundle; // FIXME: We usually don't reach this when the application is killed! // So, we don't do our cleanup. Log.i(TAG, "Tor process " + mHandle + " has exited."); - EventDispatcher.getInstance().dispatch(TOR_EVENT_EXITED, data); + EventDispatcher.getInstance().dispatch(EVENT_TOR_EXITED, data); }
private void cleanIpcDirectory() { @@ -432,4 +466,71 @@ import org.mozilla.gecko.util.GeckoBundle; } } } + + public static class BootstrapState { + // FIXME: We can do better than this :) + public GeckoBundle mBundle; + + BootstrapState(GeckoBundle bundle) { + mBundle = bundle; + } + } + + public interface BootstrapStateChangeListener { + void onBootstrapStateChange(BootstrapState state); + } + + public @NonNull GeckoResult<GeckoBundle> getSettings() { + return EventDispatcher.getInstance().queryBundle(EVENT_SETTINGS_GET); + } + + public @NonNull GeckoResult<Void> setSettings(final TorSettings settings) { + return EventDispatcher.getInstance().queryVoid(EVENT_SETTINGS_SET, settings.asGeckoBundle()); + } + + public @NonNull GeckoResult<Void> applySettings() { + return EventDispatcher.getInstance().queryVoid(EVENT_SETTINGS_APPLY); + } + + public @NonNull GeckoResult<Void> saveSettings() { + return EventDispatcher.getInstance().queryVoid(EVENT_SETTINGS_SAVE); + } + + public @NonNull GeckoResult<Void> beginBootstrap() { + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_BEGIN); + } + + public @NonNull GeckoResult<Void> beginAutoBootstrap(final String countryCode) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("countryCode", countryCode); + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_BEGIN_AUTO, bundle); + } + + public @NonNull GeckoResult<Void> beginAutoBootstrap() { + return beginAutoBootstrap(null); + } + + public @NonNull GeckoResult<Void> cancelBootstrap() { + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_CANCEL); + } + + public @NonNull GeckoResult<BootstrapState> getBootstrapState() { + return EventDispatcher.getInstance().queryBundle(EVENT_BOOTSTRAP_GET_STATE).map(new GeckoResult.OnValueMapper<>() { + @AnyThread + @Nullable + public BootstrapState onValue(@Nullable GeckoBundle value) throws Throwable { + return new BootstrapState(value); + } + }); + } + + public void registerBootstrapStateChangeListener(BootstrapStateChangeListener listener) { + mBootstrapStateListeners.add(listener); + } + + public void unregisterBootstrapStateChangeListener(BootstrapStateChangeListener listener) { + mBootstrapStateListeners.remove(listener); + } + + private final HashSet<BootstrapStateChangeListener> mBootstrapStateListeners = new HashSet<>(); }
===================================== mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorSettings.java ===================================== @@ -0,0 +1,172 @@ +package org.mozilla.geckoview; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.mozilla.gecko.util.GeckoBundle; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.io.SequenceInputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +public class TorSettings { + + public enum BridgeSource { + Invalid(-1), + BuiltIn(0), + BridgeDB(1), + UserProvided(2); + + private int source; + + BridgeSource(final int source) { + this.source = source; + } + + public static BridgeSource fromInt(int i) { + switch (i) { + case -1: return Invalid; + case 0: return BuiltIn; + case 1: return BridgeDB; + case 2: return UserProvided; + } + return Invalid; + } + + public int toInt() { + return this.source; + } + } + + public enum ProxyType { + Invalid(-1), + Socks4(0), + Socks5(1), + HTTPS(2); + + private int type; + + ProxyType(final int type) { + this.type = type; + } + + public int toInt() { + return type; + } + + public static ProxyType fromInt(int i) { + switch (i) { + case -1: return Invalid; + case 0: return Socks4; + case 1: return Socks5; + case 2: return HTTPS; + } + return Invalid; + } + } + + private boolean loaded = false; + + public boolean enabled = true; + + public boolean quickstart = false; + + // bridges section + public boolean bridgesEnabled = false; + public BridgeSource bridgesSource = BridgeSource.Invalid; + public String bridgesBuiltinType = ""; + public String[] bridgeBridgeStrings; + + // proxy section + public boolean proxyEnabled = false; + public ProxyType proxyType = ProxyType.Invalid; + public String proxyAddress = ""; + public int proxyPort = 0; + public String proxyUsername = ""; + public String proxyPassword = ""; + + // firewall section + public boolean firewallEnabled = false; + public int[] firewallAllowedPorts; + + public TorSettings() { + } + + public TorSettings(GeckoBundle bundle) { + try { + GeckoBundle qs = bundle.getBundle("quickstart"); + GeckoBundle bridges = bundle.getBundle("bridges"); + GeckoBundle proxy = bundle.getBundle("proxy"); + GeckoBundle firewall = bundle.getBundle("firewall"); + + bridgesEnabled = bridges.getBoolean("enabled"); + bridgesSource = BridgeSource.fromInt(bridges.getInt("source")); + bridgesBuiltinType = bridges.getString("builtin_type"); + bridgeBridgeStrings = bridges.getStringArray("bridge_strings"); + + quickstart = qs.getBoolean("enabled"); + + firewallEnabled = firewall.getBoolean("enabled"); + firewallAllowedPorts = firewall.getIntArray("allowed_ports"); + + proxyEnabled = proxy.getBoolean("enabled"); + proxyAddress = proxy.getString("address"); + proxyUsername = proxy.getString("username"); + proxyPassword = proxy.getString("password"); + proxyPort = proxy.getInt("port"); + proxyType = ProxyType.fromInt(proxy.getInt("type")); + + loaded = true; + } catch (Exception e) { + Log.e("TorSettings", "bundle access error: " + e.toString(), e); + } + } + + public GeckoBundle asGeckoBundle() { + GeckoBundle bundle = new GeckoBundle(); + + GeckoBundle qs = new GeckoBundle(); + GeckoBundle bridges = new GeckoBundle(); + GeckoBundle proxy = new GeckoBundle(); + GeckoBundle firewall = new GeckoBundle(); + + bridges.putBoolean("enabled", bridgesEnabled); + bridges.putInt("source", bridgesSource.toInt()); + bridges.putString("builtin_type", bridgesBuiltinType); + bridges.putStringArray("bridge_strings", bridgeBridgeStrings); + + qs.putBoolean("enabled", quickstart); + + firewall.putBoolean("enabled", firewallEnabled); + firewall.putIntArray("allowed_ports", firewallAllowedPorts); + + proxy.putBoolean("enabled", proxyEnabled); + proxy.putString("address", proxyAddress); + proxy.putString("username", proxyUsername); + proxy.putString("password", proxyPassword); + proxy.putInt("port", proxyPort); + proxy.putInt("type", proxyType.toInt()); + + bundle.putBundle("quickstart", qs); + bundle.putBundle("bridges", bridges); + bundle.putBundle("proxy", proxy); + bundle.putBundle("firewall", firewall); + + return bundle; + } + + public boolean isLoaded() { + return this.loaded; + } +}
===================================== mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/Prefs.java ===================================== @@ -0,0 +1,64 @@ +package org.mozilla.geckoview.androidlegacysettings; + +import android.content.Context; +import android.content.SharedPreferences; +import org.mozilla.gecko.GeckoAppShell; + +import java.util.Locale; + +// tor-android-service utils/Prefs.java + +/* package */ class Prefs { + private final static String PREF_BRIDGES_ENABLED = "pref_bridges_enabled"; + private final static String PREF_BRIDGES_LIST = "pref_bridges_list"; + + private static SharedPreferences prefs; + + // OrbotConstants + private final static String PREF_TOR_SHARED_PREFS = "org.torproject.android_preferences"; + + + // tor-android-service utils/TorServiceUtil.java + + private static void setContext() { + if (prefs == null) { + prefs = GeckoAppShell.getApplicationContext().getSharedPreferences(PREF_TOR_SHARED_PREFS, + Context.MODE_MULTI_PROCESS); + } + } + + public static boolean getBoolean(String key, boolean def) { + setContext(); + return prefs.getBoolean(key, def); + } + + public static void putBoolean(String key, boolean value) { + setContext(); + prefs.edit().putBoolean(key, value).apply(); + } + + public static void putString(String key, String value) { + setContext(); + prefs.edit().putString(key, value).apply(); + } + + public static String getString(String key, String def) { + setContext(); + return prefs.getString(key, def); + } + + public static boolean bridgesEnabled() { + setContext(); + return prefs.getBoolean(PREF_BRIDGES_ENABLED, false); + } + + public static String getBridgesList() { + setContext(); + // was "meek" for (Locale.getDefault().getLanguage().equals("fa")) and "obfs4" for the rest from a 2019 commit + // but that has stopped representing a good default sometime since so not importing for new users + String list = prefs.getString(PREF_BRIDGES_LIST, ""); + return list; + } + + +}
===================================== mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/TorLegacyAndroidSettings.java ===================================== @@ -0,0 +1,84 @@ +package org.mozilla.geckoview.androidlegacysettings; + +import java.io.IOException; + +import android.content.SharedPreferences; + +import org.mozilla.gecko.GeckoAppShell; + +import org.mozilla.geckoview.TorSettings; + +public class TorLegacyAndroidSettings { + + private static String PREF_USE_MOZ_PREFS = "tor_use_moz_prefs"; + + public static boolean unmigrated() { + return !Prefs.getBoolean(PREF_USE_MOZ_PREFS, false); + } + + public static void setUnmigrated() { + Prefs.putBoolean(PREF_USE_MOZ_PREFS, false); + } + + public static void setMigrated() { + Prefs.putBoolean(PREF_USE_MOZ_PREFS, true); + } + + public static TorSettings loadTorSettings() { + TorSettings settings = new TorSettings(); + + // always true, tor is enabled in TB + settings.enabled = true; + + // firefox-android disconnected quick start a while ago so it's untracked + settings.quickstart = false; + + settings.bridgesEnabled = Prefs.bridgesEnabled(); + + // tor-android-service CustomTorInstaller.java +/* + BridgesList is an overloaded field, which can cause some confusion. + The list can be: + 1) a filter like obfs4, meek, or snowflake OR + 2) it can be a custom bridge + For (1), we just pass back all bridges, the filter will occur + elsewhere in the library. + For (2) we return the bridge list as a raw stream. + If length is greater than 9, then we know this is a custom bridge + */ + String userDefinedBridgeList = Prefs.getBridgesList(); + boolean userDefinedBridge = userDefinedBridgeList.length() > 9; + // Terrible hack. Must keep in sync with topl::addBridgesFromResources. + if (!userDefinedBridge) { + settings.bridgesSource = TorSettings.BridgeSource.BuiltIn; + switch (userDefinedBridgeList) { + case "obfs4": + settings.bridgesBuiltinType = "objs4"; + break; + case "meek": + settings.bridgesBuiltinType = "meek_azure"; + break; + case "snowflake": + settings.bridgesBuiltinType = "snowflake"; + break; + default: + settings.bridgesSource = TorSettings.BridgeSource.Invalid; + break; + } + } else { + settings.bridgesSource = TorSettings.BridgeSource.UserProvided; // user provided + settings.bridgeBridgeStrings = userDefinedBridgeList.split("\r\n"); + } + + // Tor Browser Android doesn't take proxy and firewall settings + settings.proxyEnabled = false; + + settings.firewallEnabled = false; + settings.firewallAllowedPorts = new int[0]; + + return settings; + } +} + + +
===================================== toolkit/modules/TorAndroidIntegration.sys.mjs ===================================== @@ -8,6 +8,8 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", TorConnect: "resource://gre/modules/TorConnect.sys.mjs", + TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs", + TorSettingsTopics: "resource://gre/modules/TorSettings.sys.mjs", TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", TorSettings: "resource://gre/modules/TorSettings.sys.mjs", }); @@ -23,11 +25,22 @@ const logger = new ConsoleAPI({ prefix: "TorAndroidIntegration", });
+const EmittedEvents = Object.freeze( { + settingsReady: "GeckoView:Tor:SettingsReady", + settingsChanged: "GeckoView:Tor:SettingsChanged", +}); + const ListenedEvents = Object.freeze({ settingsGet: "GeckoView:Tor:SettingsGet", + // The data is passed directly to TorSettings. settingsSet: "GeckoView:Tor:SettingsSet", settingsApply: "GeckoView:Tor:SettingsApply", settingsSave: "GeckoView:Tor:SettingsSave", + bootstrapBegin: "GeckoView:Tor:BootstrapBegin", + // Optionally takes a countryCode, as data.countryCode. + bootstrapBeginAuto: "GeckoView:Tor:BootstrapBeginAuto", + bootstrapCancel: "GeckoView:Tor:BootstrapCancel", + bootstrapGetState: "GeckoView:Tor:BootstrapGetState", });
class TorAndroidIntegrationImpl { @@ -41,6 +54,14 @@ class TorAndroidIntegrationImpl {
this.#bootstrapMethodReset(); Services.prefs.addObserver(Prefs.useNewBootstrap, this); + + for (const topic in lazy.TorConnectTopics) { + Services.obs.addObserver(this, lazy.TorConnectTopics[topic]); + } + + for (const topic in lazy.TorSettingsTopics) { + Services.obs.addObserver(this, lazy.TorSettingsTopics[topic]); + } }
async #initNewBootstrap() { @@ -67,6 +88,14 @@ class TorAndroidIntegrationImpl { this.#bootstrapMethodReset(); } break; + case lazy.TorConnectTopics.StateChange: + break; + case lazy.TorSettingsTopics.Ready: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.settingsReady, + settings: lazy.TorSettings.getSettings(), + }); + break; } }
@@ -74,24 +103,36 @@ class TorAndroidIntegrationImpl { logger.debug(`Received event ${event}`, data); try { switch (event) { - case settingsGet: + case ListenedEvents.settingsGet: callback?.onSuccess(lazy.TorSettings.getSettings()); return; - case settingsSet: + case ListenedEvents.settingsSet: // This does not throw, so we do not have any way to report the error! lazy.TorSettings.setSettings(data); break; - case settingsApply: + case ListenedEvents.settingsApply: await lazy.TorSettings.applySettings(); break; - case settingsSave: + case ListenedEvents.settingsSave: await lazy.TorSettings.saveSettings(); break; + case ListenedEvents.bootstrapBegin: + lazy.TorConnect.beginBootstrap(); + break; + case ListenedEvents.bootstrapBeginAuto: + lazy.TorConnect.beginAutoBootstrap(data.countryCode); + break; + case ListenedEvents.bootstrapCancel: + lazy.TorConnect.cancelBootstrap(); + break; + case ListenedEvents.bootstrapGetState: + callback?.onSuccess(lazy.TorConnect.state); + return; } callback?.onSuccess(); } catch (e) { - logger.error(); - callback?.sendError(e); + logger.error(`Error while handling event ${event}`, e); + callback?.onError(e); } }
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/d823a7e...
tor-commits@lists.torproject.org