diff --git a/client/resources/original_messages.json b/client/resources/original_messages.json index 48d2219180..85a3b7f6ab 100644 --- a/client/resources/original_messages.json +++ b/client/resources/original_messages.json @@ -655,6 +655,38 @@ } } }, + "splitTunnelingSettingButtonLabel": { + "description": "Label for a button or link in settings to open the app exclusion/split tunneling configuration dialog.", + "message": "App Exclusion" + }, + "splitTunnelingDialogTitle": { + "description": "Title for the dialog where users can select which apps should use the VPN.", + "message": "App Exclusion" + }, + "splitTunnelingDialogSaveButton": { + "description": "Text for the 'Save' button in the app exclusion dialog.", + "message": "Save" + }, + "splitTunnelingDialogCancelButton": { + "description": "Text for the 'Cancel' button in the app exclusion dialog.", + "message": "Cancel" + }, + "splitTunnelingDialogDescription": { + "description": "Descriptive text explaining how the app exclusion (split tunneling) feature works.", + "message": "When App Exclusion is on, only apps selected here will use the VPN. All other apps will bypass the VPN. If no apps are selected, all apps will use the VPN." + }, + "splitTunnelingDialogAllAppsLabel": { + "description": "A label shown above the list of applications in the app exclusion dialog.", + "message": "All installed apps" + }, + "splitTunnelingFeatureNotSupported": { + "description": "Message shown if the app exclusion feature is not supported on the current device/platform.", + "message": "App exclusion is not supported on this device." + }, + "splitTunnelingNoAppsFound": { + "description": "Message shown in the app exclusion dialog if no configurable apps are found on the device.", + "message": "No apps found to configure." + }, "yes": { "description": "Affirmative answer to a form question.", "message": "Yes" diff --git a/client/src/cordova/android/OutlineAndroidLib/outline/src/main/java/org/outline/vpn/VpnTunnelService.java b/client/src/cordova/android/OutlineAndroidLib/outline/src/main/java/org/outline/vpn/VpnTunnelService.java index 346f0b4956..cc60d89ff1 100644 --- a/client/src/cordova/android/OutlineAndroidLib/outline/src/main/java/org/outline/vpn/VpnTunnelService.java +++ b/client/src/cordova/android/OutlineAndroidLib/outline/src/main/java/org/outline/vpn/VpnTunnelService.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Random; import java.util.logging.Level; @@ -114,6 +115,7 @@ public enum MessageData { private NetworkConnectivityMonitor networkConnectivityMonitor; private VpnTunnelStore tunnelStore; private Notification.Builder notificationBuilder; + private List allowedApplications; private final IVpnTunnelService.Stub binder = new IVpnTunnelService.Stub() { @Override @@ -135,6 +137,11 @@ public boolean isTunnelActive(String tunnelId) { public void initErrorReporting(String apiKey) { VpnTunnelService.this.initErrorReporting(apiKey); } + + @Override + public void setAllowedApplications(List packageNames) { + VpnTunnelService.this.setAllowedApplications(packageNames); + } }; @Override @@ -194,6 +201,12 @@ public void onDestroy() { // Tunnel API + /** Sets the list of allowed applications for split tunneling. */ + public void setAllowedApplications(List packageNames) { + LOG.info(String.format(Locale.ROOT, "Setting allowed applications: %s", packageNames)); + this.allowedApplications = packageNames; + } + /** This is the entry point when called from the Outline plugin, via the service IPC. */ private DetailedJsonError startTunnel(@NonNull final TunnelConfig config) { return Errors.toDetailedJsonError(startTunnel(config, false)); @@ -275,8 +288,23 @@ private synchronized PlatformError startTunnel( // TODO(fortuna): dynamically select it. .addAddress("10.111.222.1", 24) .addDnsServer(dnsResolver) - .setBlocking(true) - .addDisallowedApplication(this.getPackageName()); + .setBlocking(true); + // Always disallow this application, as it is the VPN controller. + builder.addDisallowedApplication(this.getPackageName()); + + if (this.allowedApplications != null && !this.allowedApplications.isEmpty()) { + LOG.info(String.format(Locale.ROOT, "Adding %d allowed applications.", this.allowedApplications.size())); + for (String packageName : this.allowedApplications) { + try { + builder.addAllowedApplication(packageName); + LOG.fine(String.format(Locale.ROOT, "Added allowed application: %s", packageName)); + } catch (PackageManager.NameNotFoundException e) { + LOG.warning(String.format(Locale.ROOT, "Package not found, cannot add to allowed list: %s", packageName)); + } + } + } else { + LOG.info("No allowed applications configured, all applications will use the VPN."); + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { builder.setMetered(false); diff --git a/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java b/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java index 0b6acec487..724506cc29 100644 --- a/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java +++ b/client/src/cordova/plugin/android/java/org/outline/OutlinePlugin.java @@ -22,11 +22,15 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; import android.net.VpnService; import android.os.IBinder; import android.os.RemoteException; import androidx.annotation.Nullable; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.logging.Level; @@ -62,7 +66,8 @@ public enum Action { IS_RUNNING("isRunning"), INIT_ERROR_REPORTING("initializeErrorReporting"), REPORT_EVENTS("reportEvents"), - QUIT("quitApplication"); + QUIT("quitApplication"), + GET_INSTALLED_APPS("getInstalledApps"); private final static Map actions = new HashMap<>(); static { @@ -91,9 +96,13 @@ public boolean is(final String action) { private static class StartVpnRequest { public final JSONArray args; public final CallbackContext callback; - public StartVpnRequest(JSONArray args, CallbackContext callback) { + // Store allowedApplications separately as JSONArray doesn't directly support List + public final List allowedApplications; + + public StartVpnRequest(JSONArray args, CallbackContext callback, List allowedApplications) { this.args = args; this.callback = callback; + this.allowedApplications = allowedApplications; } } @@ -173,11 +182,24 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo // Prepare the VPN before spawning a new thread. Fall through if it's already prepared. try { if (!prepareVpnService()) { - startVpnRequest = new StartVpnRequest(args, callbackContext); + List allowedApplications = null; + if (args.length() > 3) { + JSONArray allowedAppsJson = args.optJSONArray(3); + if (allowedAppsJson != null) { + allowedApplications = new ArrayList<>(); + for (int i = 0; i < allowedAppsJson.length(); ++i) { + allowedApplications.add(allowedAppsJson.getString(i)); + } + } + } + startVpnRequest = new StartVpnRequest(args, callbackContext, allowedApplications); return true; } } catch (ActivityNotFoundException e) { sendActionResult(callbackContext, new PlatformError(Platerrors.InternalError, e.toString())); + } catch (JSONException e) { + LOG.log(Level.SEVERE, "Failed to parse allowedApplications from JSONArray", e); + sendActionResult(callbackContext, new PlatformError(Platerrors.InvalidOutlineInvite, e.toString())); return true; } } @@ -209,7 +231,17 @@ private void executeAsync( final String tunnelId = args.getString(0); final String serverName = args.getString(1); final String transportConfig = args.getString(2); - sendActionResult(callback, startVpnTunnel(tunnelId, transportConfig, serverName)); + List allowedApplications = null; + if (args.length() > 3) { + JSONArray allowedAppsJson = args.optJSONArray(3); + if (allowedAppsJson != null) { + allowedApplications = new ArrayList<>(); + for (int i = 0; i < allowedAppsJson.length(); ++i) { + allowedApplications.add(allowedAppsJson.getString(i)); + } + } + } + sendActionResult(callback, startVpnTunnel(tunnelId, transportConfig, serverName, allowedApplications)); } else if (Action.STOP.is(action)) { final String tunnelId = args.getString(0); LOG.info(String.format(Locale.ROOT, "Stopping VPN tunnel %s", tunnelId)); @@ -230,6 +262,8 @@ private void executeAsync( final String uuid = args.getString(0); SentryErrorReporter.send(uuid); callback.success(); + } else if (Action.GET_INSTALLED_APPS.is(action)) { + callback.success(getInstalledApplications()); } else { throw new IllegalArgumentException( String.format(Locale.ROOT, "Unexpected action %s", action)); @@ -272,9 +306,20 @@ public void onActivityResult(int request, int result, Intent data) { } private DetailedJsonError startVpnTunnel( - final String tunnelId, final String transportConfig, final String serverName + final String tunnelId, final String transportConfig, final String serverName, @Nullable final List allowedApplications ) throws RemoteException { LOG.info(String.format(Locale.ROOT, "Starting VPN tunnel %s for server %s", tunnelId, serverName)); + if (vpnTunnelService == null) { + return Errors.toDetailedJsonError(new PlatformError(Platerrors.IllegalState, "VPN service not connected")); + } + if (allowedApplications != null && !allowedApplications.isEmpty()) { + LOG.info(String.format(Locale.ROOT, "Setting %d allowed applications", allowedApplications.size())); + vpnTunnelService.setAllowedApplications(allowedApplications); + } else { + // Explicitly clear if null or empty to reset previous settings + LOG.info("Clearing allowed applications list."); + vpnTunnelService.setAllowedApplications(new ArrayList<>()); + } final TunnelConfig tunnelConfig = new TunnelConfig(); tunnelConfig.id = tunnelId; tunnelConfig.name = serverName; @@ -354,4 +399,28 @@ private void sendActionResult(final CallbackContext callback, @Nullable Detailed callback.error(error.errorJson); } } + + private JSONArray getInstalledApplications() { + PackageManager pm = cordova.getActivity().getPackageManager(); + List packages = pm.getInstalledApplications(PackageManager.GET_META_DATA); + JSONArray apps = new JSONArray(); + for (ApplicationInfo appInfo : packages) { + try { + // Filter out system apps to provide a cleaner list to the user. + // Also filter out the Outline app itself. + if ((appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0 || + appInfo.packageName.equals(this.cordova.getActivity().getPackageName())) { + continue; + } + JSONObject app = new JSONObject(); + app.put("packageName", appInfo.packageName); + app.put("label", pm.getApplicationLabel(appInfo).toString()); + apps.put(app); + } catch (JSONException e) { + LOG.log(Level.WARNING, "Failed to serialize app info to JSON", e); + } + } + LOG.info(String.format(Locale.ROOT, "Found %d installed non-system applications.", apps.length())); + return apps; + } } diff --git a/client/src/www/app/app.spec.ts b/client/src/www/app/app.spec.ts index 8f1408475a..e715873fc1 100644 --- a/client/src/www/app/app.spec.ts +++ b/client/src/www/app/app.spec.ts @@ -53,3 +53,175 @@ describe('isOutlineAccessKey', () => { it('detects static keys', () => expect(isOutlineAccessKey('ss://myhost.com:3333')).toBe(true)); it('detects dynamic keys', () => expect(isOutlineAccessKey('ssconf://my.cool.server.com:3423#veryfast')).toBe(true)); }); + +// Add tests for the App class +import { App } from './app'; +import { EventQueue } from '../model/events'; +import { ServerRepository, Server } from '../model/server'; +import { Settings, SettingsKey } from './settings'; +import { FakeClipboard } from './clipboard'; +import { FakeErrorReporter } from '../shared/error_reporter'; +import { EnvironmentVariables } from './environment'; +import { FakeUpdater } from './updater'; +import { FakeVpnInstaller } from './vpn_installer'; +import { ServerConnectionState } from '../views/servers_view'; + +describe('App', () => { + let app: App; + let mockEventQueue: EventQueue; + let mockServerRepo: jasmine.SpyObj; + let mockRootEl: any; + let mockClipboard: FakeClipboard; + let mockErrorReporter: FakeErrorReporter; + let mockSettings: jasmine.SpyObj; + let mockEnvironment: EnvironmentVariables; + let mockUpdater: FakeUpdater; + let mockInstaller: FakeVpnInstaller; + let mockQuitApplication: jasmine.Spy<() => void>; + let mockDocument: Document; + + beforeEach(() => { + mockEventQueue = new EventQueue(); // Real EventQueue, can spy on its methods if needed + mockServerRepo = jasmine.createSpyObj('ServerRepository', ['getById', 'getAll', 'add', 'forget', 'rename', 'updateServer', 'undoForget']); + mockRootEl = { + // Mock Polymer element properties and methods as needed by App constructor and tested methods + $: { + serversView: jasmine.createSpyObj('serversView', ['addEventListener']), + addServerView: { open: false, accessKeyValidator: null }, + privacyView: { open: false }, + drawer: { open: false }, + autoConnectDialog: { open: false } + }, + servers: [], + localize: (msgId: string) => msgId, // Simple mock localize + setLanguage: jasmine.createSpy('setLanguage'), + changePage: jasmine.createSpy('changePage'), + showToast: jasmine.createSpy('showToast'), + showErrorDetails: jasmine.createSpy('showErrorDetails'), + addEventListener: jasmine.createSpy('addEventListener'), // For App's own event listeners + DEFAULT_PAGE: 'servers', + // Mock other properties if App's constructor or tested methods access them + }; + mockClipboard = new FakeClipboard(); + mockErrorReporter = new FakeErrorReporter(); + mockSettings = jasmine.createSpyObj('Settings', ['get', 'set']); + mockEnvironment = { APP_VERSION: '1.0.0', APP_BUILD_NUMBER: '1' }; + mockUpdater = new FakeUpdater(); + mockInstaller = new FakeVpnInstaller(); + mockQuitApplication = jasmine.createSpy('quitApplication'); + mockDocument = document; // Or a more specific mock if needed + + // Provide default return values for settings + mockSettings.get.and.callFake((key: SettingsKey) => { + if (key === SettingsKey.PRIVACY_ACK) return 'true'; + return undefined; + }); + + app = new App( + mockEventQueue, + mockServerRepo, + mockRootEl, + false, // debugMode + undefined, // urlInterceptor + mockClipboard, + mockErrorReporter, + mockSettings, + mockEnvironment, + mockUpdater, + mockInstaller, + mockQuitApplication, + mockDocument + ); + }); + + describe('handleUpdateServerConfig', () => { + let mockServer: jasmine.SpyObj; + const serverId = 'server123'; + const initialAllowedApps = ['com.initial.app']; + const newAllowedApps = ['com.new.app1', 'com.new.app2']; + + beforeEach(() => { + mockServer = jasmine.createSpyObj('Server', ['checkRunning', 'connect', 'disconnect']); + mockServer.id = serverId; + mockServer.name = 'Test Server'; + mockServer.allowedApps = [...initialAllowedApps]; // Start with initial set + mockServerRepo.getById.and.returnValue(mockServer); + }); + + it('should update server.allowedApps and call serverRepo.updateServer', async () => { + mockServer.checkRunning.and.returnValue(Promise.resolve(false)); // Simulate server not running + + // Directly call the method - it's private, so for testing purposes, we cast 'app' to 'any' + // or it would need to be made protected/internal for easier testing. + // A better way might be to trigger the event on rootEl if the listener is set up. + // For simplicity, we'll assume direct call is possible for this test. + await (app as any).handleUpdateServerConfig({ + detail: { serverId, allowedApps: newAllowedApps, propertyName: 'allowedApps' }, + } as CustomEvent); + + expect(mockServerRepo.getById).toHaveBeenCalledWith(serverId); + expect(mockServer.allowedApps).toEqual(newAllowedApps); + expect(mockServerRepo.updateServer).toHaveBeenCalledWith(mockServer); + expect(mockRootEl.showToast).toHaveBeenCalledWith('Settings saved', 2000); + }); + + it('should not call disconnect/connect if server is not running', async () => { + mockServer.checkRunning.and.returnValue(Promise.resolve(false)); + + await (app as any).handleUpdateServerConfig({ + detail: { serverId, allowedApps: newAllowedApps, propertyName: 'allowedApps' }, + } as CustomEvent); + + expect(mockServer.disconnect).not.toHaveBeenCalled(); + expect(mockServer.connect).not.toHaveBeenCalled(); + }); + + it('should call disconnect and connect if server is running', async () => { + mockServer.checkRunning.and.returnValue(Promise.resolve(true)); // Simulate server running + mockServer.disconnect.and.returnValue(Promise.resolve()); + mockServer.connect.and.returnValue(Promise.resolve()); + + await (app as any).handleUpdateServerConfig({ + detail: { serverId, allowedApps: newAllowedApps, propertyName: 'allowedApps' }, + } as CustomEvent); + + expect(mockServer.disconnect).toHaveBeenCalled(); + expect(mockServer.connect).toHaveBeenCalled(); // connect is called after disconnect + expect(mockRootEl.showToast).toHaveBeenCalledWith('Reconnecting server to apply changes...', 3000); + expect(mockRootEl.showToast).toHaveBeenCalledWith('Server reconnected with new settings.', 3000); + }); + + it('should only handle "allowedApps" propertyName', async () => { + await (app as any).handleUpdateServerConfig({ + detail: { serverId, someOtherData: ['test'], propertyName: 'someOtherProperty' }, + } as CustomEvent); + + expect(mockServerRepo.getById).toHaveBeenCalledWith(serverId); + expect(mockServer.allowedApps).toEqual(initialAllowedApps); // Should not change + expect(mockServerRepo.updateServer).not.toHaveBeenCalled(); + }); + }); + + // Basic test for connectServer to ensure it uses the server object which would contain allowedApps + describe('connectServer', () => { + it('should call server.connect()', async () => { + const serverId = 'serverConnectTest'; + const mockServerObj = jasmine.createSpyObj('Server', ['connect', 'checkRunning', 'disconnect']); + mockServerObj.id = serverId; + mockServerObj.name = 'Connect Test Server'; + mockServerObj.allowedApps = ['com.specific.app']; // Server object has allowedApps + + mockServerRepo.getById.and.returnValue(mockServerObj); + mockServerObj.connect.and.returnValue(Promise.resolve()); + + // Simulate the event that triggers connectServer + const event = new CustomEvent('ConnectPressed', { detail: { serverId } }); + await (app as any).connectServer(event); // Cast to any to access private method for test + + expect(mockServerRepo.getById).toHaveBeenCalledWith(serverId); + expect(mockServerObj.connect).toHaveBeenCalled(); + // Further testing that OutlineServer.connect passes allowedApps to vpnApi.start + // is implicitly covered by vpn.cordova.spec.ts and the OutlineServer implementation. + }); + }); +}); diff --git a/client/src/www/app/app.ts b/client/src/www/app/app.ts index afafe43e88..63652cd56c 100644 --- a/client/src/www/app/app.ts +++ b/client/src/www/app/app.ts @@ -238,6 +238,12 @@ export class App { this.onServerReconnecting.bind(this) ); + // Listen for config updates from views + this.rootEl.addEventListener( + 'update-server-config', + this.handleUpdateServerConfig.bind(this) + ); + this.eventQueue.startPublishing(); this.rootEl.$.addServerView.accessKeyValidator = async ( @@ -905,4 +911,55 @@ export class App { this.rootEl.selectedAppearance = appearance; } + + private async handleUpdateServerConfig( + event: CustomEvent<{serverId: string; allowedApps?: string[]; propertyName: string}> + ) { + const {serverId, propertyName} = event.detail; + + const server = this.serverRepo.getById(serverId); + if (!server) { + console.error(`Server not found for ID: ${serverId} while trying to update ${propertyName}`); + return; + } + + let configChanged = false; + if (propertyName === 'allowedApps' && 'allowedApps' in event.detail) { + server.allowedApps = event.detail.allowedApps; + configChanged = true; + console.log(`Updated allowedApps for server ${serverId}:`, server.allowedApps); + } else { + console.warn(`Unsupported property to update: ${propertyName} or missing data.`); + return; + } + + if (configChanged) { + this.serverRepo.updateServer(server); // Persist the changes + this.rootEl.showToast(this.localize('Settings saved'), 2000); + + try { + const isRunning = await server.checkRunning(); + if (isRunning) { + console.log(`Server ${serverId} is running. Reconnecting to apply settings change.`); + this.updateServerListItem(serverId, {connectionState: ServerConnectionState.DISCONNECTING}); + this.rootEl.showToast(this.localize('Reconnecting server to apply changes...'), 3000); + + await server.disconnect(); + // Brief pause to ensure resources are released before reconnecting. + await new Promise(resolve => setTimeout(resolve, 500)); + + this.updateServerListItem(serverId, {connectionState: ServerConnectionState.CONNECTING}); + await server.connect(); // Assumes server.connect() now uses the updated server.allowedApps + + this.updateServerListItem(serverId, {connectionState: ServerConnectionState.CONNECTED}); + this.rootEl.showToast(this.localize('Server reconnected with new settings.'), 3000); + } + } catch (e) { + console.error(`Error during reconnect for server ${serverId} after settings change:`, e); + this.showLocalizedError(e); + // Attempt to re-sync UI to whatever the actual current state is. + await this.syncServerConnectivityState(server); + } + } + } } diff --git a/client/src/www/app/outline_server_repository/index.ts b/client/src/www/app/outline_server_repository/index.ts index 1f83bb494f..7cc02f7d3d 100644 --- a/client/src/www/app/outline_server_repository/index.ts +++ b/client/src/www/app/outline_server_repository/index.ts @@ -67,6 +67,7 @@ interface OutlineServerJson { readonly id: string; readonly accessKey: string; readonly name: string; + readonly allowedApps?: string[]; // Added for split tunneling } type ServerEntry = {accessKey: string; server: Server}; @@ -200,6 +201,22 @@ class OutlineServerRepository implements ServerRepository { this.lastForgottenServer = null; } + updateServer(server: Server) { + if (!this.serverById.has(server.id)) { + console.warn(`Cannot update nonexistent server ${server.id}`); + return; + } + // Assuming the server object passed is the same one held in the map, + // or has the updated properties. If it's a different instance, + // we might need to update the entry in serverById map explicitly. + // For properties like 'allowedApps', direct modification of the object + // obtained from getById() is expected to be reflected here when storeServers() is called. + this.storeServers(); + // Optionally, dispatch an event if other parts of the app need to know about a generic update. + // For now, specific events like ServerRenamed are used. + // this.eventQueue.enqueue(new events.ServerUpdated(server)); + } + private serverFromAccessKey(accessKey: string): Server | undefined { const trimmedAccessKey = accessKey.trim(); for (const {accessKey, server} of this.serverById.values()) { @@ -217,6 +234,7 @@ class OutlineServerRepository implements ServerRepository { id: server.id, accessKey, name: server.name, + allowedApps: server.allowedApps, // Store allowedApps }); } const json = JSON.stringify(servers); @@ -226,7 +244,8 @@ class OutlineServerRepository implements ServerRepository { async internalCreateServer( id: string, accessKey: string, - name?: string + name?: string, + allowedApps?: string[] // Added allowedApps parameter ): Promise { const server = await newOutlineServer( this.vpnApi, @@ -234,7 +253,15 @@ class OutlineServerRepository implements ServerRepository { name, accessKey, this.localize + // allowedApps is not directly passed to newOutlineServer, + // but newOutlineServer creates an OutlineServer instance. + // The OutlineServer constructor itself will need to handle the allowedApps. + // Let's ensure the OutlineServer instance within `server` object gets this value. ); + // Explicitly set allowedApps on the created server instance if provided + if (allowedApps) { + server.allowedApps = allowedApps; + } this.serverById.set(id, {accessKey, server}); return server; } @@ -295,7 +322,8 @@ async function loadServersV1(storage: Storage, repo: OutlineServerRepository) { await repo.internalCreateServer( serverJson.id, serverJson.accessKey, - serverJson.name + serverJson.name, + serverJson.allowedApps // Pass loaded allowedApps ); } catch (e) { // Don't propagate so other stored servers can be created. diff --git a/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts b/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts index 3e1a887170..d8d9ed341a 100644 --- a/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts +++ b/client/src/www/app/outline_server_repository/outline_server_repository.spec.ts @@ -79,11 +79,13 @@ describe('OutlineServerRepository', () => { id: 'server-0', name: 'fake server 0', accessKey: serversStorageV0ConfigToAccessKey(CONFIG_0_V0), + allowedApps: ['com.app.one'], // Test loading this }, { id: 'server-1', name: 'renamed server', accessKey: serversStorageV0ConfigToAccessKey(CONFIG_1_V0), + // No allowedApps here, should be undefined or empty on load }, ]; const storage = new InMemoryStorage( @@ -95,11 +97,14 @@ describe('OutlineServerRepository', () => { const repo = await newTestRepo(new EventQueue(), storage); const server0 = repo.getById('server-0'); expect(server0?.name).toEqual(CONFIG_0_V0.name); + expect(server0?.allowedApps).toEqual(['com.app.one']); // Verify loaded allowedApps + const server1 = repo.getById('server-1'); expect(server1?.name).toEqual('renamed server'); + expect(server1?.allowedApps).toBeUndefined(); // Or .toEqual([]) depending on implementation choice in repo }); - it('stores V1 servers', async () => { + it('stores V1 servers with allowedApps', async () => { const storageV0: ServersStorageV0 = { 'server-0': {...CONFIG_0_V0, name: CONFIG_0_V0.name}, 'server-1': {...CONFIG_1_V0, name: CONFIG_1_V0.name}, @@ -108,26 +113,32 @@ describe('OutlineServerRepository', () => { new Map([[TEST_ONLY.SERVERS_STORAGE_KEY_V0, JSON.stringify(storageV0)]]) ); const repo = await newTestRepo(new EventQueue(), storage); - // Trigger storage change. - repo.forget('server-1'); - repo.undoForget('server-1'); + + // Modify a server to include allowedApps + const server0 = repo.getById('server-0'); + expect(server0).toBeDefined(); + if (!server0) return; // Type guard + server0.allowedApps = ['com.test.app', 'com.another.app']; + repo.updateServer(server0); // Explicitly call updateServer to trigger storeServers const item = storage.getItem(TEST_ONLY.SERVERS_STORAGE_KEY) ?? ''; expect(item).toBeTruthy; - const serversJson = JSON.parse(item); - expect(serversJson).toContain({ - id: 'server-0', - name: 'fake server 0', - accessKey: serversStorageV0ConfigToAccessKey(CONFIG_0_V0), - }); - expect(serversJson).toContain({ - id: 'server-1', - name: 'fake server 1', - accessKey: serversStorageV0ConfigToAccessKey(CONFIG_1_V0), - }); + const serversJson: ServersStorageV1 = JSON.parse(item); + + const storedServer0 = serversJson.find(s => s.id === 'server-0'); + expect(storedServer0).toBeDefined(); + expect(storedServer0?.name).toEqual(CONFIG_0_V0.name); // Name might be from original V0 config + expect(storedServer0?.accessKey).toEqual(serversStorageV0ConfigToAccessKey(CONFIG_0_V0)); + expect(storedServer0?.allowedApps).toEqual(['com.test.app', 'com.another.app']); + + const storedServer1 = serversJson.find(s => s.id === 'server-1'); + expect(storedServer1).toBeDefined(); + expect(storedServer1?.name).toEqual(CONFIG_1_V0.name); // Name might be from original V0 config + expect(storedServer1?.accessKey).toEqual(serversStorageV0ConfigToAccessKey(CONFIG_1_V0)); + expect(storedServer1?.allowedApps).toBeUndefined(); // Or .toEqual([]) }); - it('add stores servers', async () => { + it('add stores servers without allowedApps if not set', async () => { const storage = new InMemoryStorage(); const repo = await newTestRepo(new EventQueue(), storage); const accessKey0 = serversStorageV0ConfigToAccessKey(CONFIG_0_V0); @@ -136,15 +147,17 @@ describe('OutlineServerRepository', () => { await repo.add(accessKey1); const item = storage.getItem(TEST_ONLY.SERVERS_STORAGE_KEY) ?? ''; expect(item).toBeTruthy; - const servers: ServersStorageV1 = JSON.parse(item); - expect(servers.length).toEqual(2); - expect(servers[0].accessKey).toEqual(accessKey0); - expect(servers[0].name).toEqual(CONFIG_0_V0.name); - expect(servers[1].accessKey).toEqual(accessKey1); - expect(servers[1].name).toEqual(CONFIG_1_V0.name); + const serversJson: ServersStorageV1 = JSON.parse(item); + expect(serversJson.length).toEqual(2); + expect(serversJson[0].accessKey).toEqual(accessKey0); + expect(serversJson[0].name).toEqual(CONFIG_0_V0.name); + expect(serversJson[0].allowedApps).toBeUndefined(); // Or .toEqual([]) + expect(serversJson[1].accessKey).toEqual(accessKey1); + expect(serversJson[1].name).toEqual(CONFIG_1_V0.name); + expect(serversJson[1].allowedApps).toBeUndefined(); // Or .toEqual([]) }); - it('add emits ServerAdded event', async () => { + it('add emits ServerAdded event, server should not have allowedApps initially', async () => { const eventQueue = new EventQueue(); const repo = await newTestRepo(eventQueue, new InMemoryStorage()); const accessKey = serversStorageV0ConfigToAccessKey(CONFIG_0_V0); diff --git a/client/src/www/app/outline_server_repository/server.ts b/client/src/www/app/outline_server_repository/server.ts index 62d43cd08d..978fb9f911 100644 --- a/client/src/www/app/outline_server_repository/server.ts +++ b/client/src/www/app/outline_server_repository/server.ts @@ -69,22 +69,33 @@ export async function newOutlineServer( class OutlineServer implements Server { errorMessageId?: string; private tunnelConfig?: FirstHopAndTunnelConfigJson; + public allowedApps?: string[]; // Added property constructor( private vpnApi: VpnApi, readonly id: string, public name: string, - private serviceConfig: ServiceConfig + private serviceConfig: ServiceConfig, + allowedApps?: string[] // Optional: initialize from constructor if available from storage ) { if (serviceConfig instanceof StaticServiceConfig) { this.tunnelConfig = serviceConfig.tunnelConfig; } + this.allowedApps = allowedApps; // Initialize the property } + // Removed extra brace here get address() { return this.tunnelConfig?.firstHop || ''; } + get tunnelConfigLocation(): URL | undefined { + if (this.serviceConfig instanceof DynamicServiceConfig) { + return this.serviceConfig.transportConfigLocation; + } + return undefined; + } + async connect() { if (this.serviceConfig instanceof DynamicServiceConfig) { this.tunnelConfig = await fetchTunnelConfig( @@ -97,6 +108,7 @@ class OutlineServer implements Server { id: this.id, name: this.name, config: this.tunnelConfig, + allowedApplications: this.allowedApps, // Pass allowed apps }; await this.vpnApi.start(request); } catch (cause) { diff --git a/client/src/www/app/outline_server_repository/vpn.cordova.spec.ts b/client/src/www/app/outline_server_repository/vpn.cordova.spec.ts new file mode 100644 index 0000000000..aca4746c83 --- /dev/null +++ b/client/src/www/app/outline_server_repository/vpn.cordova.spec.ts @@ -0,0 +1,175 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CordovaVpnApi } from './vpn.cordova'; +import { StartRequestJson, TunnelStatus } from './vpn'; +import { OUTLINE_PLUGIN_NAME } from '../plugin.cordova'; +import { IllegalServerConfiguration } from '../../model/errors'; + +describe('CordovaVpnApi', () => { + let vpnApi: CordovaVpnApi; + let mockCordovaExec: jasmine.Spy; + + beforeEach(() => { + vpnApi = new CordovaVpnApi(); + mockCordovaExec = spyOn(window.cordova, 'exec'); + }); + + describe('start', () => { + const MOCK_REQUEST_BASE: Omit = { + id: 'server1', + name: 'My Server', + config: { + client: { + accessKey: 'ss://mock-key', + method: 'chacha20-ietf-poly1305', + password: 'password', + serverAddress: '127.0.0.1', + serverPort: 12345, + }, + firstHop: '127.0.0.1:12345', + }, + }; + + it('should throw IllegalServerConfiguration if config is missing', () => { + const request = { id: 'id', name: 'name' } as StartRequestJson; // Missing config + expect(() => vpnApi.start(request)).toThrowError(IllegalServerConfiguration); + }); + + it('should call cordova.exec with correct parameters including allowedApplications', async () => { + const request: StartRequestJson = { + ...MOCK_REQUEST_BASE, + allowedApplications: ['com.example.app1', 'com.example.app2'], + }; + await vpnApi.start(request); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), // success + jasmine.any(Function), // error + OUTLINE_PLUGIN_NAME, + 'start', + [ + request.id, + request.name, + request.config.client, + request.allowedApplications, + ] + ); + }); + + it('should pass an empty array for allowedApplications if undefined', async () => { + const request: StartRequestJson = { ...MOCK_REQUEST_BASE, allowedApplications: undefined }; + await vpnApi.start(request); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Function), + OUTLINE_PLUGIN_NAME, + 'start', + [ + request.id, + request.name, + request.config.client, + [], // Expect empty array + ] + ); + }); + + it('should pass an empty array for allowedApplications if null', async () => { + const request: StartRequestJson = { ...MOCK_REQUEST_BASE, allowedApplications: null as any }; + await vpnApi.start(request); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Function), + OUTLINE_PLUGIN_NAME, + 'start', + [ + request.id, + request.name, + request.config.client, + [], // Expect empty array + ] + ); + }); + + it('should pass an empty array for allowedApplications if it is an empty array', async () => { + const request: StartRequestJson = { ...MOCK_REQUEST_BASE, allowedApplications: [] }; + await vpnApi.start(request); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Function), + OUTLINE_PLUGIN_NAME, + 'start', + [ + request.id, + request.name, + request.config.client, + [], // Expect empty array + ] + ); + }); + }); + + describe('stop', () => { + it('should call cordova.exec with correct parameters', async () => { + const serverId = 'server1'; + await vpnApi.stop(serverId); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Function), + OUTLINE_PLUGIN_NAME, + 'stop', + [serverId] + ); + }); + }); + + describe('isRunning', () => { + it('should call cordova.exec with correct parameters', async () => { + const serverId = 'server1'; + await vpnApi.isRunning(serverId); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Function), + OUTLINE_PLUGIN_NAME, + 'isRunning', + [serverId] + ); + }); + }); + + describe('onStatusChange', () => { + it('should call cordova.exec with correct parameters for onStatusChange', () => { + const listener = jasmine.createSpy('listener'); + vpnApi.onStatusChange(listener); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), // The internal callback that calls the listener + jasmine.any(Function), // error callback + OUTLINE_PLUGIN_NAME, + 'onStatusChange', + [] + ); + }); + + it('listener should be called when plugin invokes the success callback', () => { + const listener = jasmine.createSpy('listener'); + vpnApi.onStatusChange(listener); + + // Simulate the plugin calling the success callback + const mockPluginResponse = {id: 'server1', status: TunnelStatus.CONNECTED}; + const successCallback = mockCordovaExec.calls.mostRecent().args[0]; + successCallback(mockPluginResponse); + + expect(listener).toHaveBeenCalledWith(mockPluginResponse.id, mockPluginResponse.status); + }); + }); +}); diff --git a/client/src/www/app/outline_server_repository/vpn.cordova.ts b/client/src/www/app/outline_server_repository/vpn.cordova.ts index 4e976b94ba..a6328546da 100644 --- a/client/src/www/app/outline_server_repository/vpn.cordova.ts +++ b/client/src/www/app/outline_server_repository/vpn.cordova.ts @@ -28,7 +28,8 @@ export class CordovaVpnApi implements VpnApi { // TODO(fortuna): Make the Cordova plugin take a StartRequestJson. request.id, request.name, - request.config.client + request.config.client, + request.allowedApplications || [] // Pass allowedApplications or empty array ); } diff --git a/client/src/www/app/outline_server_repository/vpn.ts b/client/src/www/app/outline_server_repository/vpn.ts index b3ca93b530..3ee7fde292 100644 --- a/client/src/www/app/outline_server_repository/vpn.ts +++ b/client/src/www/app/outline_server_repository/vpn.ts @@ -26,6 +26,7 @@ export interface StartRequestJson { id: string; name: string; config: FirstHopAndTunnelConfigJson; + allowedApplications?: string[]; // Added: For Android split tunneling } /** VpnApi is how we talk to the platform-specific VPN API. */ diff --git a/client/src/www/app/plugin.cordova.spec.ts b/client/src/www/app/plugin.cordova.spec.ts new file mode 100644 index 0000000000..a593f47cdd --- /dev/null +++ b/client/src/www/app/plugin.cordova.spec.ts @@ -0,0 +1,70 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AppInfo, getInstalledApplications, OUTLINE_PLUGIN_NAME } from './plugin.cordova'; +import { PlatformError } from '../model/platform_error'; + +describe('Cordova Plugin Interface', () => { + let mockCordovaExec: jasmine.Spy; + + beforeEach(() => { + // Mock cordova.exec for each test + mockCordovaExec = spyOn(window.cordova, 'exec'); + }); + + describe('getInstalledApplications', () => { + it('should call cordova.exec with correct parameters', async () => { + await getInstalledApplications(); + expect(mockCordovaExec).toHaveBeenCalledWith( + jasmine.any(Function), // success callback + jasmine.any(Function), // error callback + OUTLINE_PLUGIN_NAME, + 'getInstalledApps', + [] // arguments array + ); + }); + + it('should resolve with a list of apps on success', async () => { + const mockApps: AppInfo[] = [ + { packageName: 'com.example.app1', label: 'App 1' }, + { packageName: 'com.example.app2', label: 'App 2' }, + ]; + mockCordovaExec.and.callFake((successCallback) => { + successCallback(mockApps); + }); + + const apps = await getInstalledApplications(); + expect(apps).toEqual(mockApps); + }); + + it('should reject with an error on failure', async () => { + const mockError = { message: 'Plugin error' }; + mockCordovaExec.and.callFake((_, errorCallback) => { + errorCallback(mockError); + }); + + try { + await getInstalledApplications(); + fail('Expected getInstalledApplications to reject'); + } catch (error) { + // Expecting a PlatformError due to deserializeError in pluginExec + expect(error instanceof PlatformError).toBeTrue(); + } + }); + }); + + // TODO: Add tests for other pluginExec calls if necessary, specifically for 'start' action + // to verify `allowedApplications` is passed correctly. This might be better placed + // in a test file for `vpn.cordova.ts` where `CordovaVpnApi.start` is defined. +}); diff --git a/client/src/www/app/plugin.cordova.ts b/client/src/www/app/plugin.cordova.ts index 89f3a6b7c7..315b55edf6 100644 --- a/client/src/www/app/plugin.cordova.ts +++ b/client/src/www/app/plugin.cordova.ts @@ -16,6 +16,13 @@ import {deserializeError} from '../model/platform_error'; export const OUTLINE_PLUGIN_NAME = 'OutlinePlugin'; +// Describes the structure of application information retrieved from the plugin. +export interface AppInfo { + packageName: string; + label: string; + // icon?: string; // Future: consider adding app icons +} + // Helper function to call the Outline Cordova plugin. export async function pluginExec( cmd: string, @@ -26,3 +33,8 @@ export async function pluginExec( cordova.exec(resolve, wrappedReject, OUTLINE_PLUGIN_NAME, cmd, args); }); } + +// Fetches the list of installed applications from the native plugin. +export async function getInstalledApplications(): Promise { + return pluginExec('getInstalledApps'); +} diff --git a/client/src/www/model/server.ts b/client/src/www/model/server.ts index f8a99af69a..4a7c72be64 100644 --- a/client/src/www/model/server.ts +++ b/client/src/www/model/server.ts @@ -32,6 +32,10 @@ export interface Server { // must match one of the localized app message. errorMessageId?: string; + // For Android, a list of package names for apps that should be allowed to use the VPN. + // If undefined or empty, all apps will use the VPN (default behavior). + allowedApps?: string[]; + // Connects to the server, redirecting the device's traffic. connect(): Promise; @@ -49,4 +53,5 @@ export interface ServerRepository { undoForget(serverId: string): void; getAll(): Server[]; getById(serverId: string): Server | undefined; + updateServer(server: Server): void; // Added to persist changes to a server object } diff --git a/client/src/www/ui_components/app_selection_dialog.spec.ts b/client/src/www/ui_components/app_selection_dialog.spec.ts new file mode 100644 index 0000000000..38e61945f1 --- /dev/null +++ b/client/src/www/ui_components/app_selection_dialog.spec.ts @@ -0,0 +1,199 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {html, PolymerElement} from '@polymer/polymer/polymer-element.js'; +import {flush} from '@polymer/polymer/lib/utils/flush.js'; + +// Import the component to test +import './app_selection_dialog'; +import {AppSelectionDialog, AppSelectionDialogSaveEventDetail} from './app_selection_dialog'; + +// Mock dependencies +import * as pluginCordova from '../app/plugin.cordova'; +import {AppInfo} from '../app/plugin.cordova'; + +describe('AppSelectionDialog', () => { + let dialog: AppSelectionDialog; + let getInstalledApplicationsSpy: jasmine.Spy<() => Promise>; + + const mockApps: AppInfo[] = [ + {packageName: 'com.app.one', label: 'App One'}, + {packageName: 'com.app.two', label: 'App Two'}, + {packageName: 'com.app.three', label: 'App Three'}, + ]; + + // Helper to create and attach the dialog to the DOM + async function createDialog(): Promise { + const element = document.createElement('app-selection-dialog') as AppSelectionDialog; + // Set a localize function for testing + element.localize = (msgId: string, ...params: string[]) => { + let value = msgId; + if (params) { + for (let i = 0; i < params.length; i = i + 2) { + value = value.replace(params[i], params[i+1]); + } + } + return value; + }; + document.body.appendChild(element); + await flush(); // Wait for Polymer to render + return element; + } + + beforeEach(async () => { + // Spy on and mock getInstalledApplications before each test + getInstalledApplicationsSpy = spyOn(pluginCordova, 'getInstalledApplications'); + getInstalledApplicationsSpy.and.returnValue(Promise.resolve([...mockApps])); // Default success + + dialog = await createDialog(); + }); + + afterEach(() => { + // Clean up the dialog from the DOM + if (dialog && dialog.parentNode) { + dialog.parentNode.removeChild(dialog); + } + }); + + it('should be defined as a custom element', () => { + expect(customElements.get('app-selection-dialog')).toBeDefined(); + }); + + it('should call getInstalledApplications on ready/attached', () => { + expect(getInstalledApplicationsSpy).toHaveBeenCalled(); + }); + + it('should populate the apps list from getInstalledApplications', async () => { + await getInstalledApplicationsSpy.calls.mostRecent().returnValue; // Wait for the promise + await flush(); + expect(dialog.apps.length).toBe(mockApps.length); + expect(dialog.apps[0].packageName).toEqual(mockApps[0].packageName); + + const items = dialog.shadowRoot.querySelectorAll('paper-item'); + expect(items.length).toBe(mockApps.length); + expect(items[0].textContent.includes(mockApps[0].label)).toBeTrue(); + }); + + it('should display "No apps found" message if no apps are returned', async () => { + getInstalledApplicationsSpy.and.returnValue(Promise.resolve([])); + dialog = await createDialog(); // Recreate with new spy behavior + await getInstalledApplicationsSpy.calls.mostRecent().returnValue; + await flush(); + + expect(dialog.apps.length).toBe(0); + const noAppsMessageItem = dialog.shadowRoot.querySelector('paper-item'); + expect(noAppsMessageItem).not.toBeNull(); + expect(noAppsMessageItem.textContent.includes('splitTunnelingNoAppsFound')).toBeTrue(); + }); + + it('should handle errors from getInstalledApplications gracefully', async () => { + getInstalledApplicationsSpy.and.returnValue(Promise.reject(new Error('Plugin failed'))); + dialog = await createDialog(); // Recreate + await getInstalledApplicationsSpy.calls.mostRecent().returnValue.catch(() => {}); // Catch expected rejection + await flush(); + + expect(dialog.apps.length).toBe(0); // Apps list should remain empty + const noAppsMessageItem = dialog.shadowRoot.querySelector('paper-item'); + expect(noAppsMessageItem).not.toBeNull(); // "No apps found" should still be shown + expect(noAppsMessageItem.textContent.includes('splitTunnelingNoAppsFound')).toBeTrue(); + // Optionally, check console.error was called if you added that for error logging + }); + + describe('Selection', () => { + beforeEach(async () => { + // Ensure apps are loaded for selection tests + await getInstalledApplicationsSpy.calls.mostRecent().returnValue; + await flush(); + }); + + it('should initialize with no apps selected if currentlySelectedApps is empty', () => { + dialog.open([]); + expect(Object.values(dialog.selectedApps).every(selected => !selected)).toBeTrue(); + const checkboxes = dialog.shadowRoot.querySelectorAll('paper-checkbox'); + checkboxes.forEach(cb => expect((cb as any).checked).toBeFalse()); + }); + + it('should initialize with specified apps selected', () => { + dialog.open([mockApps[0].packageName, mockApps[2].packageName]); + expect(dialog.selectedApps[mockApps[0].packageName]).toBeTrue(); + expect(dialog.selectedApps[mockApps[1].packageName]).toBeFalse(); + expect(dialog.selectedApps[mockApps[2].packageName]).toBeTrue(); + + const checkboxes = dialog.shadowRoot.querySelectorAll('paper-checkbox'); + expect((checkboxes[0] as any).checked).toBeTrue(); + expect((checkboxes[1] as any).checked).toBeFalse(); + expect((checkboxes[2] as any).checked).toBeTrue(); + }); + + it('should update selectedApps when an app item is tapped', () => { + dialog.open([]); + const paperItems = dialog.shadowRoot.querySelectorAll('paper-item'); + + // Tap first item + (paperItems[0] as HTMLElement).click(); + await flush(); + expect(dialog.selectedApps[mockApps[0].packageName]).toBeTrue(); + expect((dialog.shadowRoot.querySelectorAll('paper-checkbox')[0] as any).checked).toBeTrue(); + + // Tap first item again to deselect + (paperItems[0] as HTMLElement).click(); + await flush(); + expect(dialog.selectedApps[mockApps[0].packageName]).toBeFalse(); + expect((dialog.shadowRoot.querySelectorAll('paper-checkbox')[0] as any).checked).toBeFalse(); + }); + }); + + describe('Dialog Actions', () => { + beforeEach(async () => { + await getInstalledApplicationsSpy.calls.mostRecent().returnValue; + await flush(); + dialog.open([mockApps[0].packageName]); // Start with one app selected + }); + + it('should dispatch "save-selected-apps" event with selected apps on save', (done) => { + // Select another app + const paperItems = dialog.shadowRoot.querySelectorAll('paper-item'); + (paperItems[1] as HTMLElement).click(); // Select mockApps[1] + + dialog.addEventListener('save-selected-apps', (event: CustomEvent) => { + expect(event.detail.selectedApps).toBeDefined(); + expect(event.detail.selectedApps.length).toBe(2); + expect(event.detail.selectedApps).toContain(mockApps[0].packageName); + expect(event.detail.selectedApps).toContain(mockApps[1].packageName); + // Check dialog is closed by _onSave + expect((dialog as any).dialogElement.opened).toBeFalse(); + done(); + }); + + const saveButton = dialog.shadowRoot.querySelector('paper-button[dialog-confirm]') as HTMLElement; + saveButton.click(); + }); + + it('should close the dialog on cancel and revert selections', async () => { + const initialSelections = {...dialog.selectedApps}; + + // Change selection + const paperItems = dialog.shadowRoot.querySelectorAll('paper-item'); + (paperItems[1] as HTMLElement).click(); // Select mockApps[1] + expect(dialog.selectedApps[mockApps[1].packageName]).toBeTrue(); + + const cancelButton = dialog.shadowRoot.querySelector('paper-button[dialog-dismiss]') as HTMLElement; + cancelButton.click(); + await flush(); + + expect((dialog as any).dialogElement.opened).toBeFalse(); + expect(dialog.selectedApps).toEqual(initialSelections); // Check selections are reverted + }); + }); +}); diff --git a/client/src/www/ui_components/app_selection_dialog.ts b/client/src/www/ui_components/app_selection_dialog.ts new file mode 100644 index 0000000000..fa272b7d96 --- /dev/null +++ b/client/src/www/ui_components/app_selection_dialog.ts @@ -0,0 +1,206 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '@polymer/paper-dialog/paper-dialog.js'; +import '@polymer/paper-listbox/paper-listbox.js'; +import '@polymer/paper-item/paper-item.js'; +import '@polymer/paper-checkbox/paper-checkbox.js'; +import '@polymer/paper-button/paper-button.js'; +import '@polymer/iron-icon/iron-icon.js'; +import '@polymer/paper-dialog-scrollable/paper-dialog-scrollable.js'; + +import {PolymerElement, html} from '@polymer/polymer/polymer-element.js'; +import {customElement, property, query} from '@polymer/decorators'; +import {AppLocalizeBehavior} from '@polymer/app-localize-behavior/app-localize-behavior.js'; +import {PaperDialogElement} from '@polymer/paper-dialog/paper-dialog.js'; // Added import + +import {AppInfo, getInstalledApplications} from '../app/plugin.cordova'; +// Removed LocalizeMixin import + +// Define the event detail for when the dialog saves. +export interface AppSelectionDialogSaveEventDetail { + selectedApps: string[]; // Array of package names +} + +@customElement('app-selection-dialog') +export class AppSelectionDialog extends AppLocalizeBehavior(PolymerElement) { + static readonly template = html` + + + +

[[localize('splitTunnelingDialogTitle')]]

+ +
[[localize('splitTunnelingDialogDescription')]]
+ + + + +
+
+ [[localize('splitTunnelingDialogCancelButton')]] + [[localize('splitTunnelingDialogSaveButton')]] +
+
+ `; + + @property({type: Array, value: () => []}) apps: AppInfo[] = []; + + @property({type: Object, value: () => ({})}) + selectedApps: {[packageName: string]: boolean} = {}; + + // Stores the initially selected apps when the dialog is opened to compare on save. + private initialSelectedApps: {[packageName: string]: boolean} = {}; + + @query('#dialog') private dialogElement!: PaperDialogElement; + + ready() { + super.ready(); + // Load localization resources + this.loadResources(this.resolveUrl('../messages/en.json')); + this._loadInstalledApps(); + } + + open(currentlySelectedApps: string[] = []) { + const newSelectedApps: {[packageName: string]: boolean} = {}; + for (const appPkg of currentlySelectedApps) { + newSelectedApps[appPkg] = true; + } + this.selectedApps = newSelectedApps; + // Store a copy for comparison on save, to see if changes were made. + this.initialSelectedApps = {...this.selectedApps}; + this.dialogElement.open(); + } + + private async _loadInstalledApps() { + try { + // TODO: Add a loading indicator + this.apps = await getInstalledApplications(); + // Ensure selectedApps object is populated for any apps that might have been + // selected previously but are just now being loaded. + // Create a new object for selectedApps to ensure Polymer notices the change if needed. + const updatedSelectedApps = {...this.selectedApps}; + this.apps.forEach(app => { + if (updatedSelectedApps[app.packageName] === undefined) { + updatedSelectedApps[app.packageName] = false; + } + }); + this.selectedApps = updatedSelectedApps; + } catch (e) { + console.error('Failed to load installed applications', e); + // TODO: Show error to user, perhaps using the splitTunnelingFeatureNotSupported message + // For now, just leave the apps list empty, which will show "No apps found". + } + } + + private _isAppSelected(packageName: string): boolean { + return !!this.selectedApps[packageName]; + } + + // paper-item on-tap handler to toggle the checkbox state + private _toggleAppSelection(event: Event) { + const item = (event.currentTarget as HTMLElement).closest('paper-item'); + // In Polymer 3, modelForElement might not be the standard way. + // Assuming dom-repeat sets up `app` in the item's context. + const app = (item as any).app as AppInfo; // Or use a more robust way to get app data + if (app && app.packageName) { + const updatedSelectedApps = {...this.selectedApps}; + updatedSelectedApps[app.packageName] = !this.selectedApps[app.packageName]; + this.selectedApps = updatedSelectedApps; + } else { + // Fallback or error if app context is not found as expected + const checkbox = item.querySelector('paper-checkbox') as any; + if (checkbox) { + // This is less ideal as it relies on finding the app by checkbox state indirectly + // and requires knowing which app corresponds to this checkbox. + // The dom-repeat model is preferred. + console.warn("Could not find app context directly, attempting fallback for checkbox toggle."); + } + } + } + + private _onSave() { + const newSelectedAppsList: string[] = []; + for (const pkgName in this.selectedApps) { + if (this.selectedApps[pkgName]) { + newSelectedAppsList.push(pkgName); + } + } + + // Only dispatch event if the selection has actually changed. + // This prevents unnecessary processing if the user opens and saves without changes. + // For a more robust check, compare newSelectedAppsList with an initial list. + // For simplicity now, we'll always dispatch. + // A more robust check would involve converting initialSelectedApps to a list and comparing. + + this.dispatchEvent( + new CustomEvent('save-selected-apps', { + bubbles: true, + composed: true, + detail: {selectedApps: newSelectedAppsList}, + }) + ); + this.dialogElement.close(); + } + + private _onCancel() { + // Restore the selection to what it was when the dialog opened. + this.selectedApps = {...this.initialSelectedApps}; + this.dialogElement.close(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'app-selection-dialog': AppSelectionDialog; + } +} diff --git a/client/src/www/views/servers_view/index.ts b/client/src/www/views/servers_view/index.ts index 5c0ba1a751..3f0269a115 100644 --- a/client/src/www/views/servers_view/index.ts +++ b/client/src/www/views/servers_view/index.ts @@ -188,8 +188,30 @@ export class ServerList extends LitElement { ?darkMode=${this.darkMode} .servers=${this.servers} .localize=${this.localize} + @set-server-allowed-apps=${this._handleSetServerAllowedApps} > `; } } + + private async _handleSetServerAllowedApps( + event: CustomEvent<{serverId: string; allowedApps: string[]}> + ) { + const {serverId, allowedApps} = event.detail; + // Dispatch an event for a higher-level component (e.g., root-view) to handle. + // This component doesn't directly manage the server repository or connection logic. + this.dispatchEvent( + new CustomEvent('update-server-config', { + detail: {serverId, allowedApps, propertyName: 'allowedApps'}, // Added propertyName for clarity + bubbles: true, + composed: true, + }) + ); + // Optional: Provide immediate feedback to the user if necessary, + // though the actual change will be processed by the handler of 'update-server-config'. + console.log( + `Server ${serverId} requested allowed apps update:`, + allowedApps + ); + } } diff --git a/client/src/www/views/servers_view/server_list/index.ts b/client/src/www/views/servers_view/server_list/index.ts index 4dd8cf0122..8ac198eadc 100644 --- a/client/src/www/views/servers_view/server_list/index.ts +++ b/client/src/www/views/servers_view/server_list/index.ts @@ -16,7 +16,7 @@ import {css, html, LitElement} from 'lit'; import {customElement, property} from 'lit/decorators.js'; import '../server_list_item/server_card'; -import {ServerListItem} from '../server_list_item'; +import {ServerListItem, ServerListItemEvent} from '../server_list_item'; // Added ServerListItemEvent @customElement('server-list') export class ServerList extends LitElement { @@ -54,6 +54,7 @@ export class ServerList extends LitElement { ?darkMode=${this.darkMode} .localize=${this.localize} .server=${this.servers[0]} + @${ServerListItemEvent.SET_ALLOWED_APPS}=${this._handleSetAllowedApps} >`; } else { return html` @@ -63,12 +64,25 @@ export class ServerList extends LitElement { ?darkMode=${this.darkMode} .localize=${this.localize} .server=${server} + @${ServerListItemEvent.SET_ALLOWED_APPS}=${this._handleSetAllowedApps} >` )} `; } } + private _handleSetAllowedApps(event: CustomEvent<{serverId: string; allowedApps: string[]}>) { + // Re-dispatch an event that a higher-level component can handle. + // This component doesn't have direct access to the server repository. + this.dispatchEvent( + new CustomEvent('set-server-allowed-apps', { + bubbles: true, + composed: true, + detail: event.detail, + }) + ); + } + private get hasSingleServer() { return this.servers.length === 1; } diff --git a/client/src/www/views/servers_view/server_list_item/index.ts b/client/src/www/views/servers_view/server_list_item/index.ts index e439b9a726..6884a6a924 100644 --- a/client/src/www/views/servers_view/server_list_item/index.ts +++ b/client/src/www/views/servers_view/server_list_item/index.ts @@ -23,6 +23,7 @@ export enum ServerListItemEvent { DISCONNECT = 'DisconnectPressed', FORGET = 'ForgetPressed', RENAME = 'RenameRequested', + SET_ALLOWED_APPS = 'SetAllowedApps', // New event } /** @@ -35,6 +36,7 @@ export interface ServerListItem { id: string; name: string; connectionState: ServerConnectionState; + allowedApps?: string[]; // New optional property } /** @@ -47,4 +49,5 @@ export interface ServerListItemElement { menu: Ref; menuButton: Ref; isRenameDialogOpen: boolean; + isAppSelectionDialogOpen: boolean; // Added property } diff --git a/client/src/www/views/servers_view/server_list_item/server_card/index.ts b/client/src/www/views/servers_view/server_list_item/server_card/index.ts index 08b16039d3..d1b032d699 100644 --- a/client/src/www/views/servers_view/server_list_item/server_card/index.ts +++ b/client/src/www/views/servers_view/server_list_item/server_card/index.ts @@ -20,6 +20,8 @@ import {createRef, Ref, ref} from 'lit/directives/ref.js'; import '../../server_connection_indicator'; import './server_rename_dialog'; +import '../../../../ui_components/app_selection_dialog'; // Added import +import type {AppSelectionDialogSaveEventDetail} from '../../../../ui_components/app_selection_dialog'; // Added import import {ServerListItem, ServerListItemElement, ServerListItemEvent} from '..'; import {ServerConnectionState} from '../../server_connection_indicator'; @@ -179,6 +181,7 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => { composed: true, }) ), + beginAppSelection: () => (element.isAppSelectionDialogOpen = true), // Added dispatcher connectToggle: () => element.dispatchEvent( new CustomEvent( @@ -234,6 +237,9 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => { ${localize('server-forget')} + + ${localize('splitTunnelingSettingButtonLabel')} + `, menuButton: html` @@ -265,6 +271,24 @@ const getSharedComponents = (element: ServerListItemElement & LitElement) => { @cancel=${() => (element.isRenameDialogOpen = false)} @submit=${dispatchers.submitRename} >`, + appSelectionDialog: html` + ) => { + element.isAppSelectionDialogOpen = false; + element.dispatchEvent( + new CustomEvent(ServerListItemEvent.SET_ALLOWED_APPS, { + detail: {serverId: element.server.id, allowedApps: event.detail.selectedApps}, + bubbles: true, + composed: true, + }) + ); + }} + @close=${() => (element.isAppSelectionDialogOpen = false)} + > + `, }, }; }; @@ -279,6 +303,7 @@ export class ServerRowCard extends LitElement implements ServerListItemElement { @property({type: Object}) localize: Localizer; @state() isRenameDialogOpen = false; + @state() isAppSelectionDialogOpen = false; // Added state menu: Ref = createRef(); menuButton: Ref = createRef(); @@ -325,7 +350,7 @@ export class ServerRowCard extends LitElement implements ServerListItemElement { ${elements.menuButton} ${elements.footer} - ${elements.menu} ${elements.renameDialog} + ${elements.menu} ${elements.renameDialog} ${elements.appSelectionDialog} `; } } @@ -343,6 +368,7 @@ export class ServerHeroCard @property({type: Boolean}) darkMode = false; @state() isRenameDialogOpen = false; + @state() isAppSelectionDialogOpen = false; // Added state menu: Ref = createRef(); menuButton: Ref = createRef(); @@ -431,7 +457,7 @@ export class ServerHeroCard ${elements.footer} - ${elements.menu} ${elements.renameDialog} + ${elements.menu} ${elements.renameDialog} ${elements.appSelectionDialog} `; } }