Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
52bbc78
VPN-5455 App Intents
mcleinman Nov 17, 2025
a190543
small tweaks
mcleinman Nov 18, 2025
b1495e4
Merge branch 'main' into vpn-5455-app-intents
mcleinman Apr 1, 2026
3412046
first pass at translation script
mcleinman Apr 4, 2026
805f525
whoops
mcleinman Apr 4, 2026
def9296
add files to xcode for building
mcleinman Apr 7, 2026
52dc373
allow for chinese
mcleinman Apr 7, 2026
243144f
error handling for turning on VPN
mcleinman Apr 7, 2026
c3d48f2
improve 'turn off' error handling
mcleinman Apr 8, 2026
a3dc6cc
improve feedback
mcleinman Apr 8, 2026
ebd763b
fix color
mcleinman Apr 8, 2026
9c8a5cf
adding in city names - but translations aren't working yet
mcleinman Apr 8, 2026
5a2c07e
update format
mcleinman Apr 9, 2026
474dad5
use string IDs to allow string interpolation to work
mcleinman Apr 10, 2026
81ab23a
add checks
mcleinman Apr 10, 2026
5c2dfca
increase liklihood of showing image
mcleinman Apr 10, 2026
99a2276
skip notifications when appropriate
mcleinman Apr 10, 2026
707a6b8
fix failing tests
mcleinman Apr 13, 2026
4255eed
a couple more Siri phrasings
mcleinman Apr 13, 2026
fa77dec
add to l10n readme
mcleinman Apr 13, 2026
65f2fe6
use generic placeholder for
mcleinman Apr 13, 2026
12fb3c6
the linter thinks it will be happier if it knows Siri exists, but onc…
mcleinman Apr 13, 2026
17ee8f6
VPN-6084 save new server info on iOS, even if disconnected
mcleinman Apr 15, 2026
1ddbc44
fix
mcleinman Apr 15, 2026
3dda22b
fewer changes needed
mcleinman Apr 15, 2026
3875e37
formatting improvements
mcleinman Apr 15, 2026
b1e4e16
PR feedback
mcleinman Apr 23, 2026
5f3cc00
one translation block per intent phrase set
mcleinman Apr 23, 2026
db1d77a
make more generic
mcleinman Apr 24, 2026
6365dc9
Merge branch 'vpn-5455-app-intents' into vpn-6084-always-update-confi…
mcleinman Apr 24, 2026
4d65b58
PR feedback
mcleinman Apr 25, 2026
0801e61
Update src/translations/strings.yaml
mcleinman Apr 28, 2026
75e5e56
Update scripts/utils/generate_xcstrings.py
mcleinman Apr 28, 2026
4632ffb
Update scripts/utils/generate_xcstrings.py
mcleinman Apr 28, 2026
f43d5ca
PR feedback
mcleinman Apr 28, 2026
63084b0
linting
mcleinman Apr 28, 2026
b439cd5
fix formatting
mcleinman Apr 28, 2026
e405fb5
fix comments
mcleinman Apr 29, 2026
2eb0baf
Merge branch 'main' into vpn-5455-app-intents
mcleinman Apr 29, 2026
970bb51
fix failing iOS build - ensure it is successful even for new strings
mcleinman Apr 29, 2026
cfa1d6c
fix linter issue from github merge
mcleinman Apr 29, 2026
1cd40a4
PR feedback
mcleinman Apr 29, 2026
71e1914
Merge branch 'vpn-5455-app-intents' into vpn-6084-always-update-confi…
mcleinman Apr 29, 2026
d8afaf9
Merge branch 'main' into vpn-6084-always-update-config-in-network-ext…
mcleinman May 1, 2026
b08c2f9
make linter happy
mcleinman May 1, 2026
91c2561
Merge branch 'main' into vpn-6084-always-update-config-in-network-ext…
mcleinman May 4, 2026
3fb7593
early return as needed
mcleinman May 5, 2026
61fb06c
better linting
mcleinman May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 69 additions & 17 deletions src/controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -354,17 +354,8 @@ void Controller::updateRequired() {
}
}

void Controller::activateInternal(
DNSPortPolicy dnsPort,
ServerSelectionPolicy serverSelectionPolicy = RandomizeServerSelection,
ActivationPrincipal initiator = ClientUser) {
logger.debug() << "Activation internal";
Q_ASSERT(m_impl);
m_initiator = initiator;

m_handshakeTimer.stop();
m_activationQueue.clear();

auto Controller::setupConfigs(DNSPortPolicy dnsPort,
ServerSelectionPolicy serverSelectionPolicy) {
Server exitServer =
serverSelectionPolicy == DoNotRandomizeServerSelection &&
!m_serverData.exitServerPublicKey().isEmpty()
Expand All @@ -374,19 +365,20 @@ void Controller::activateInternal(
if (!exitServer.initialized()) {
logger.error() << "Empty exit server list in state" << m_state;
serverUnavailable();
return;
return QList<InterfaceConfig>();
}

MozillaVPN* vpn = MozillaVPN::instance();
const Device* device = vpn->deviceModel()->currentDevice(vpn->keys());
if (!device) {
logger.warning() << "No current device. Aborting activation.";
m_nextStep = Disconnect;
return;
return QList<InterfaceConfig>();
}
SettingsHolder* settingsHolder = SettingsHolder::instance();
QList<InterfaceConfig> returnList;

auto allowedIPList = initiator == ExtensionUser
auto allowedIPList = m_initiator == ExtensionUser
? getExtensionProxyAddressRanges(exitServer)
: getAllowedIPAddressRanges(exitServer);
// Prepare the exit server's connection data.
Expand Down Expand Up @@ -442,7 +434,7 @@ void Controller::activateInternal(
if (!entryServer.initialized()) {
logger.error() << "Empty entry server list in state" << m_state;
serverUnavailable();
return;
return QList<InterfaceConfig>();
}

InterfaceConfig entryConfig;
Expand All @@ -468,7 +460,7 @@ void Controller::activateInternal(
entryConfig.m_serverPort = 53;
}

m_activationQueue.append(entryConfig);
returnList.append(entryConfig);
}
// Otherwise, we can approximate multihop support by redirecting the
// connection to the exit server via the multihop port.
Expand All @@ -486,7 +478,7 @@ void Controller::activateInternal(
if (!entryServer.initialized()) {
logger.error() << "Empty entry server list in state" << m_state;
serverUnavailable();
return;
return QList<InterfaceConfig>();
}

// NOTE: For platforms without multihop support, we cannot emulate multihop
Expand All @@ -498,6 +490,34 @@ void Controller::activateInternal(
exitConfig.m_entryCity = entryServer.cityName();
}

returnList.append(exitConfig);
return returnList;
}

void Controller::activateInternal(
DNSPortPolicy dnsPort,
ServerSelectionPolicy serverSelectionPolicy = RandomizeServerSelection,
ActivationPrincipal initiator = ClientUser) {
logger.debug() << "Activation internal";
Q_ASSERT(m_impl);
m_initiator = initiator;

m_handshakeTimer.stop();
m_activationQueue.clear();

QList<InterfaceConfig> serverConfigs =
setupConfigs(dnsPort, serverSelectionPolicy);
if (serverConfigs.isEmpty()) {
// Error in setupConfigs, so do not continue
return;
}

Q_ASSERT(serverConfigs.size() == 1 || serverConfigs.size() == 2);
InterfaceConfig exitConfig = serverConfigs.takeLast();
if (!serverConfigs.isEmpty()) {
InterfaceConfig entryConfig = serverConfigs.takeFirst();
m_activationQueue.append(entryConfig);
}
m_activationQueue.append(exitConfig);
m_serverData.setEntryServerPublicKey(
m_activationQueue.first().m_serverPublicKey);
Expand Down Expand Up @@ -873,17 +893,49 @@ void Controller::captivePortalPresent() {
void Controller::serverDataChanged() {
if (!isActive() || m_state == StateDisconnecting) {
logger.debug() << "Server data changed but we are off or disconnecting";

#ifdef MZ_IOS
// If the VPN is disconnected, we still need to update the config in the
// network extension so if the next connection comes from control center or
// app intent the latest config will be used.
logger.debug() << "However, we are on iOS so we are forwarding the config";
#else
return;
#endif
}

TaskScheduler::deleteTasks();
TaskScheduler::scheduleTask(
new TaskControllerAction(TaskControllerAction::eSwitch));
}

void Controller::maybeSendUpdatedConfig(const ServerData& serverData) {
if (m_impl->canSendUpdatedConfig()) {
logger.debug() << "Sending updated config";
m_serverData = serverData;
QList<InterfaceConfig> serverConfigs =
setupConfigs(DoNotForceDNSPort, RandomizeServerSelection);
Q_ASSERT(serverConfigs.size() == 1 || serverConfigs.size() == 2);
InterfaceConfig exitConfig = serverConfigs.takeLast();
InterfaceConfig entryConfig;
if (!serverConfigs.isEmpty()) {
entryConfig = serverConfigs.takeFirst();
m_activationQueue.append(entryConfig);
m_serverData.setEntryServerPublicKey(entryConfig.m_serverPublicKey);
} else {
m_serverData.setEntryServerPublicKey(exitConfig.m_serverPublicKey);
}
m_serverData.setExitServerPublicKey(exitConfig.m_serverPublicKey);
m_impl->sendUpdatedConfig(entryConfig, exitConfig);
} else {
logger.debug() << "Skipping sending updated config";
}
}

bool Controller::switchServers(const ServerData& serverData) {
if (!isActive()) {
logger.debug() << "Server data changed but we are off";
maybeSendUpdatedConfig(serverData);
return false;
}

Expand Down
4 changes: 4 additions & 0 deletions src/controller.h
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class Controller : public QObject, public LogSerializer {
ReasonNone = 0,
ReasonSwitching,
ReasonConfirming,
ReasonUpdating
};

enum ErrorCode {
Expand Down Expand Up @@ -276,6 +277,9 @@ class Controller : public QObject, public LogSerializer {
void setError(ErrorCode code);
void maybeEnableDisconnectInConfirming();
void serverDataChanged();
auto setupConfigs(DNSPortPolicy dnsPort,
ServerSelectionPolicy serverSelectionPolicy);
void maybeSendUpdatedConfig(const ServerData& serverData);
QString useLocalSocketPath() const;

private:
Expand Down
4 changes: 4 additions & 0 deletions src/controllerimpl.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ class ControllerImpl : public QObject {

virtual bool shouldSuppressNextNotification() { return false; }

virtual bool canSendUpdatedConfig() const { return false; }
Copy link
Copy Markdown
Collaborator

@oskirby oskirby Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some nits on the API:

  1. We don't really need canSendUpdatedConfig(), the default implementation can be a no-op for platforms that don't support it.
  2. It would be more efficient to pass the config by reference, eg: virtual void sendUpdatedConfig(const InterfaceConfig& entryConfig, const InterfaceConfig& exitConfig) {};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I wanted to avoid some future state where a developer doesn't realize that isn't implemented everywhere, and calls it as if it is. Another way of handling this that I considered is just adding a big scary comment in the code around this function that explains the situation. However, I also wanted something in the logs - and unless we make the default implementation a bit chunkier by handling logging, that has to come from controller.cpp - and thus I believe we'd need to have the boolean check so we can log something if it is not implemented. I still prefer the way I did it, but I do realize it adds a little bit of overhead here - if you feel strongly, let me know and I'll remove the boolean and log line, and replace it with some scary warning in a code comment.

  2. Good call. Updated.

virtual void sendUpdatedConfig(InterfaceConfig& entryConfig,
InterfaceConfig& exitConfig) {};

protected:
// Helper method - process a JSON status and emit the statusUpdated signal.
void emitStatusFromJson(const QJsonObject& obj);
Expand Down
5 changes: 5 additions & 0 deletions src/platforms/ios/ioscontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class IOSController final : public ControllerImpl {

bool shouldSuppressNextNotification() override;

bool canSendUpdatedConfig() const override { return true; }

void sendUpdatedConfig(InterfaceConfig& entryConfig,
InterfaceConfig& exitConfig) override;

private:
bool m_checkingStatus = false;
QString m_serverPublicKey;
Expand Down
4 changes: 4 additions & 0 deletions src/platforms/ios/ioscontroller.mm
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,7 @@ emit statusUpdated(QString::fromNSString(serverIpv4Gateway),
bool IOSController::shouldSuppressNextNotification() {
return [impl shouldSuppressNextNotification];
}

void IOSController::sendUpdatedConfig(InterfaceConfig& entryConfig, InterfaceConfig& exitConfig) {
activate(exitConfig, Controller::ReasonUpdating);
}
35 changes: 28 additions & 7 deletions src/platforms/ios/ioscontroller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ public class IOSControllerImpl: NSObject {
gleanDebugTag: String, isSuperDooperFeatureActive: Bool, installationId: String, isServerLocatedInUserCountry: Bool?,
disconnectOnErrorCallback: @escaping () -> Void, onboardingCompletedCallback: @escaping () -> Void,
vpnConfigPermissionResponseCallback: @escaping (Bool) -> Void, entryCity: String?, exitCity: String?) {
let isActivating = reason != 3
TunnelManager.withTunnel { tunnel in
guard let config = configs.first else {
IOSControllerImpl.logger.error(message: "No VPN config found")
Expand Down Expand Up @@ -234,18 +235,23 @@ public class IOSControllerImpl: NSObject {
tunnel.localizedDescription = VPN_NAME
tunnel.isEnabled = true

// Create a rule so that the VPN always connects. This allows reconnection if
// the device reboots or the network extension is stopped for an unexpected reason.
let alwaysConnect = NEOnDemandRuleConnect()
alwaysConnect.interfaceTypeMatch = .any
tunnel.isOnDemandEnabled = true
tunnel.onDemandRules = [alwaysConnect]
if isActivating {
// Create a rule so that the VPN always connects. This allows reconnection if
// the device reboots or the network extension is stopped for an unexpected reason,
// such as the magic Apple "soft reboot".
let alwaysConnect = NEOnDemandRuleConnect()
alwaysConnect.interfaceTypeMatch = .any
tunnel.isOnDemandEnabled = true
tunnel.onDemandRules = [alwaysConnect]
}

return tunnel.saveToPreferences { saveError in
// At this point, the user has made a selection on the system config permission modal to either allow or not allow
// the vpn configuration to be created, so it is safe to run activation retries via Controller::startHandshakeTimer()
// without the possibility or re-prompting (flickering) the modal while it is currently being displayed
vpnConfigPermissionResponseCallback(saveError == nil)
if isActivating {
vpnConfigPermissionResponseCallback(saveError == nil)
}

if let error = saveError {
IOSControllerImpl.logger.error(message: "Connect Tunnel Save Error: \(error)")
Expand All @@ -264,6 +270,12 @@ public class IOSControllerImpl: NSObject {

IOSControllerImpl.logger.info(message: "Loading the tunnel succeeded")

if (!isActivating) {
// No more work to do; return
IOSControllerImpl.logger.info(message: "Config saved")
return
}

do {
if (reason == 1 /* ReasonSwitching */ && TunnelManager.session?.status == .connected) {
let settings = config.asWgQuickConfig()
Expand Down Expand Up @@ -294,6 +306,15 @@ public class IOSControllerImpl: NSObject {
IOSControllerImpl.logger.info(message: "VPN already connected")
return .errorAlreadyActive
}

TunnelManager.withTunnel{ tunnel in
let alwaysConnect = NEOnDemandRuleConnect()
alwaysConnect.interfaceTypeMatch = .any
tunnel.isOnDemandEnabled = true
tunnel.onDemandRules = [alwaysConnect]
return true
}

do {
IOSControllerImpl.shouldSkipNextNotification = true
try TunnelManager.session?.startTunnel(options: ["source":"intent"])
Expand Down
Loading