From de32ebcc41c2b86f4d908c64536d7a00a91c80e5 Mon Sep 17 00:00:00 2001 From: "J. Yi" <93548144+jyyi1@users.noreply.github.com> Date: Fri, 31 Jan 2025 19:07:31 -0500 Subject: [PATCH] feat(client): add callback/event mechanism between TypeScript and Go (#2330) This PR introduces a `callback` mechanism that allows TypeScript code to subscribe to events triggered in the Go code. To demonstrate the functionality, this PR implements a VPN connection state change event. The TypeScript code can now subscribe to this event and receive updates on the VPN connection state. This mechanism can be leveraged for both electron and the Cordova code. --- client/electron/go_plugin.ts | 165 ++++++++++++++++-- client/electron/index.ts | 15 +- client/electron/vpn_service.ts | 71 ++++++-- client/go/outline/callback/callback.go | 107 ++++++++++++ client/go/outline/callback/callback_test.go | 179 ++++++++++++++++++++ client/go/outline/electron/go_plugin.go | 50 ++++++ client/go/outline/method_channel.go | 16 ++ client/go/outline/vpn/vpn.go | 48 +++++- client/go/outline/vpn_linux.go | 15 ++ client/go/outline/vpn_others.go | 5 +- 10 files changed, 626 insertions(+), 45 deletions(-) create mode 100644 client/go/outline/callback/callback.go create mode 100644 client/go/outline/callback/callback_test.go diff --git a/client/electron/go_plugin.ts b/client/electron/go_plugin.ts index 9570ea9cb60..c632182895c 100644 --- a/client/electron/go_plugin.ts +++ b/client/electron/go_plugin.ts @@ -18,8 +18,6 @@ import koffi from 'koffi'; import {pathToBackendLibrary} from './app_paths'; -let invokeMethodFunc: Function | undefined; - /** * Calls a Go function by invoking the `InvokeMethod` function in the native backend library. * @@ -36,31 +34,162 @@ export async function invokeGoMethod( method: string, input: string ): Promise { - if (!invokeMethodFunc) { - const backendLib = koffi.load(pathToBackendLibrary()); + console.debug(`[Backend] - calling InvokeMethod "${method}" ...`); + const result = await getDefaultBackendChannel().invokeMethod(method, input); + console.debug(`[Backend] - InvokeMethod "${method}" returned`, result); + if (result.ErrorJson) { + throw new Error(result.ErrorJson); + } + return result.Output; +} + +/** + * Represents a function that will be called from the Go backend. + * @param data The data string passed from the Go backend. + * @returns A result string that will be passed back to the caller. + */ +export type CallbackFunction = (data: string) => string; + +/** A token to uniquely identify a callback. */ +export type CallbackToken = number; + +/** + * Registers a callback function in TypeScript, making it invokable from Go. + * + * The caller can unregister the callback by calling `unregisterCallback`. + * + * @param callback The callback function to be registered. + * @returns A Promise resolves to the callback token, which can be used to refer to the callback. + */ +export async function registerCallback( + callback: CallbackFunction +): Promise { + console.debug('[Backend] - calling registerCallback ...'); + const token = await getDefaultCallbackManager().register(callback); + console.debug('[Backend] - registerCallback done, token:', token); + return token; +} + +/** + * Unregisters a specified callback function from the Go backend to release resources. + * + * @param token The callback token returned from `registerCallback`. + * @returns A Promise that resolves when the unregistration is done. + */ +export async function unregisterCallback(token: CallbackToken): Promise { + console.debug('[Backend] - calling unregisterCallback, token:', token); + await getDefaultCallbackManager().unregister(token); + console.debug('[Backend] - unregisterCallback done, token:', token); +} + +/** Singleton of the CGo callback manager. */ +let callback: CallbackManager | undefined; + +function getDefaultCallbackManager(): CallbackManager { + if (!callback) { + callback = new CallbackManager(getDefaultBackendChannel()); + } + return callback; +} - // Define C strings and setup auto release - const cgoString = koffi.disposable( +class CallbackManager { + /** `const char* (*CallbackFuncPtr)(const char *data);` */ + private readonly callbackFuncPtr: koffi.IKoffiCType; + + /** `int RegisterCallback(CallbackFuncPtr cb);` */ + private readonly registerCallback: Function; + + /** `void UnregisterCallback(int token);` */ + private readonly unregisterCallback: Function; + + /** + * Koffi requires us to register all persistent callbacks; we track the registrations here. + * @see https://koffi.dev/callbacks#registered-callbacks + */ + private readonly koffiCallbacks = new Map< + CallbackToken, + koffi.IKoffiRegisteredCallback + >(); + + constructor(backend: BackendChannel) { + this.callbackFuncPtr = koffi.pointer( + koffi.proto('CallbackFuncPtr', 'str', [backend.cgoString]) + ); + this.registerCallback = backend.declareCGoFunction( + 'RegisterCallback', + 'int', + [this.callbackFuncPtr] + ); + this.unregisterCallback = backend.declareCGoFunction( + 'UnregisterCallback', + 'void', + ['int'] + ); + } + + async register(callback: CallbackFunction): Promise { + const persistentCallback = koffi.register(callback, this.callbackFuncPtr); + const token = await this.registerCallback(persistentCallback); + this.koffiCallbacks.set(token, persistentCallback); + return token; + } + + async unregister(token: CallbackToken): Promise { + await this.unregisterCallback(token); + const persistentCallback = this.koffiCallbacks.get(token); + if (persistentCallback) { + koffi.unregister(persistentCallback); + this.koffiCallbacks.delete(token); + } + } +} + +/** Singleton of the CGo backend channel. */ +let backend: BackendChannel | undefined; + +function getDefaultBackendChannel(): BackendChannel { + if (!backend) { + backend = new BackendChannel(); + } + return backend; +} + +class BackendChannel { + /** The backend library instance of koffi */ + private readonly library: koffi.IKoffiLib; + + /** An auto releasable `const char *` type in koffi */ + readonly cgoString: koffi.IKoffiCType; + + /** `InvokeMethodResult InvokeMethod(char* method, char* input);` */ + readonly invokeMethod: Function; + + constructor() { + this.library = koffi.load(pathToBackendLibrary()); + + // Define shared types + this.cgoString = koffi.disposable( 'CGoAutoReleaseString', 'str', - backendLib.func('FreeCGoString', 'void', ['str']) + this.library.func('FreeCGoString', 'void', ['str']) ); - // Define InvokeMethod data structures and function const invokeMethodResult = koffi.struct('InvokeMethodResult', { - Output: cgoString, - ErrorJson: cgoString, + Output: this.cgoString, + ErrorJson: this.cgoString, }); - invokeMethodFunc = promisify( - backendLib.func('InvokeMethod', invokeMethodResult, ['str', 'str']).async + this.invokeMethod = this.declareCGoFunction( + 'InvokeMethod', + invokeMethodResult, + ['str', 'str'] ); } - console.debug(`[Backend] - calling InvokeMethod "${method}" ...`); - const result = await invokeMethodFunc(method, input); - console.debug(`[Backend] - InvokeMethod "${method}" returned`, result); - if (result.ErrorJson) { - throw Error(result.ErrorJson); + declareCGoFunction( + name: string, + result: koffi.TypeSpec, + args: koffi.TypeSpec[] + ): Function { + return promisify(this.library.func(name, result, args).async); } - return result.Output; } diff --git a/client/electron/index.ts b/client/electron/index.ts index 303a66b341a..7420ddba92d 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -39,7 +39,7 @@ import {invokeGoMethod} from './go_plugin'; import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; -import {closeVpn, establishVpn, onVpnStatusChanged} from './vpn_service'; +import {closeVpn, establishVpn, onVpnStateChanged} from './vpn_service'; import {VpnTunnel} from './vpn_tunnel'; import * as config from '../src/www/app/outline_server_repository/config'; import { @@ -59,6 +59,7 @@ declare const APP_VERSION: string; const debugMode = process.env.OUTLINE_DEBUG === 'true'; const IS_LINUX = os.platform() === 'linux'; +const USE_MODERN_ROUTING = IS_LINUX && !process.env.APPIMAGE; // Used for the auto-connect feature. There will be a tunnel in store // if the user was connected at shutdown. @@ -373,11 +374,7 @@ async function createVpnTunnel( async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { console.debug('startVpn called with request ', JSON.stringify(request)); - if (IS_LINUX && !process.env.APPIMAGE) { - onVpnStatusChanged((id, status) => { - setUiTunnelStatus(status, id); - console.info('VPN Status Changed: ', id, status); - }); + if (USE_MODERN_ROUTING) { await establishVpn(request); return; } @@ -415,7 +412,7 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) { // Invoked by both the stop-proxying event and quit handler. async function stopVpn() { - if (IS_LINUX && !process.env.APPIMAGE) { + if (USE_MODERN_ROUTING) { await Promise.all([closeVpn(), tearDownAutoLaunch()]); return; } @@ -471,6 +468,10 @@ function main() { // TODO(fortuna): Start the app with the window hidden on auto-start? setupWindow(); + if (USE_MODERN_ROUTING) { + await onVpnStateChanged(setUiTunnelStatus); + } + let requestAtShutdown: StartRequestJson | undefined; try { requestAtShutdown = await tunnelStore.load(); diff --git a/client/electron/vpn_service.ts b/client/electron/vpn_service.ts index 9cc8e5fec81..bd26ae63a21 100644 --- a/client/electron/vpn_service.ts +++ b/client/electron/vpn_service.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {invokeGoMethod} from './go_plugin'; +import {invokeGoMethod, registerCallback} from './go_plugin'; import { StartRequestJson, TunnelStatus, @@ -35,15 +35,10 @@ interface EstablishVpnRequest { transport: string; } -let currentRequestId: string | undefined = undefined; - export async function establishVpn(request: StartRequestJson) { - currentRequestId = request.id; - statusCb?.(currentRequestId, TunnelStatus.RECONNECTING); - const config: EstablishVpnRequest = { vpn: { - id: currentRequestId, + id: request.id, // TUN device name, being compatible with old code: // https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203 @@ -72,19 +67,67 @@ export async function establishVpn(request: StartRequestJson) { }; await invokeGoMethod('EstablishVPN', JSON.stringify(config)); - statusCb?.(currentRequestId, TunnelStatus.CONNECTED); } export async function closeVpn(): Promise { - statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTING); await invokeGoMethod('CloseVPN', ''); - statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTED); } -export type VpnStatusCallback = (id: string, status: TunnelStatus) => void; +export type VpnStateChangeCallback = (status: TunnelStatus, id: string) => void; + +/** + * Registers a callback function to be invoked when the VPN state changes. + * + * @param cb - The callback function to be invoked when the VPN state changes. + * The callback will receive the VPN connection ID as well as the new status. + * + * @remarks The caller should subscribe to this event **only once**. + * Use the `id` parameter in the callback to identify the firing VPN connection. + */ +export async function onVpnStateChanged( + cb: VpnStateChangeCallback +): Promise { + if (!cb) { + return; + } + + const cbToken = await registerCallback(data => { + const conn = JSON.parse(data) as VPNConnectionState; + console.debug('VPN connection state changed', conn); + switch (conn?.status) { + case VPNConnConnected: + cb(TunnelStatus.CONNECTED, conn.id); + break; + case VPNConnConnecting: + cb(TunnelStatus.RECONNECTING, conn.id); + break; + case VPNConnDisconnecting: + cb(TunnelStatus.DISCONNECTING, conn.id); + break; + case VPNConnDisconnected: + cb(TunnelStatus.DISCONNECTED, conn.id); + break; + } + return ''; + }); + + await invokeGoMethod('SetVPNStateChangeListener', cbToken.toString()); +} + +//#region type definitions of VPNConnection in Go + +// The following constants and types should be aligned with the corresponding definitions +// in `./client/go/outline/vpn/vpn.go`. -let statusCb: VpnStatusCallback | undefined = undefined; +type VPNConnStatus = string; +const VPNConnConnecting: VPNConnStatus = 'Connecting'; +const VPNConnConnected: VPNConnStatus = 'Connected'; +const VPNConnDisconnecting: VPNConnStatus = 'Disconnecting'; +const VPNConnDisconnected: VPNConnStatus = 'Disconnected'; -export function onVpnStatusChanged(cb: VpnStatusCallback): void { - statusCb = cb; +interface VPNConnectionState { + readonly id: string; + readonly status: VPNConnStatus; } + +//#endregion type definitions of VPNConnection in Go diff --git a/client/go/outline/callback/callback.go b/client/go/outline/callback/callback.go new file mode 100644 index 00000000000..8010a7fa9df --- /dev/null +++ b/client/go/outline/callback/callback.go @@ -0,0 +1,107 @@ +// Copyright 2025 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. + +// Package callback provides a thread-safe mechanism for managing and invoking callbacks. +package callback + +import ( + "log/slog" + "sync" +) + +// Token can be used to uniquely identify a registered callback. +// +// This token is designed to be used across language boundaries. +// For example, TypeScript code can use this token to reference a callback. +type Token int + +// Handler is an interface that can be implemented to receive callbacks. +type Handler interface { + // OnCall is called when the callback is invoked. It accepts an input string and + // optionally returns an output string. + OnCall(data string) string +} + +// Manager manages the registration, unregistration, and invocation of callbacks. +type Manager struct { + mu sync.RWMutex + callbacks map[Token]Handler + nextCbID Token +} + +// variables defining the DefaultManager. +var ( + mgrInstance *Manager + initMgrOnce sync.Once +) + +// DefaultManager returns the shared default callback [Manager] that can be used across +// all compoenents. +func DefaultManager() *Manager { + initMgrOnce.Do(func() { + mgrInstance = NewManager() + }) + return mgrInstance +} + +// NewManager creates a new callback [Manager]. +func NewManager() *Manager { + return &Manager{ + callbacks: make(map[Token]Handler), + nextCbID: 1, + } +} + +// Register registers a new callback to the [Manager]. +// +// It returns a unique [Token] that can be used to unregister or invoke the callback. +func (m *Manager) Register(c Handler) Token { + m.mu.Lock() + defer m.mu.Unlock() + + token := m.nextCbID + m.nextCbID++ + m.callbacks[token] = c + slog.Debug("callback created", "token", token) + return token +} + +// Unregister removes a previously registered callback from the [Manager]. +// +// It is safe to call this function with a non-registered [Token]. +func (m *Manager) Unregister(token Token) { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.callbacks, token) + slog.Debug("callback deleted", "token", token) +} + +// Call invokes the callback identified by the given [Token]. +// +// It passes data to the [Handler]'s OnCall method and returns the result. +// +// It is safe to call this function with a non-registered [Token]. +func (m *Manager) Call(token Token, data string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + cb, ok := m.callbacks[token] + if !ok { + slog.Warn("callback not yet registered", "token", token) + return "" + } + slog.Debug("invoking callback", "token", token, "data", data) + return cb.OnCall(data) +} diff --git a/client/go/outline/callback/callback_test.go b/client/go/outline/callback/callback_test.go new file mode 100644 index 00000000000..69430e3685b --- /dev/null +++ b/client/go/outline/callback/callback_test.go @@ -0,0 +1,179 @@ +// Copyright 2025 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. + +package callback + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Register(t *testing.T) { + mgr := NewManager() + token := mgr.Register(&testCallback{}) + require.Equal(t, Token(1), token) + require.Contains(t, mgr.callbacks, token) + require.Equal(t, Token(2), mgr.nextCbID) +} + +func Test_Unregister(t *testing.T) { + mgr := NewManager() + token := mgr.Register(&testCallback{}) + require.Equal(t, Token(1), token) + require.Contains(t, mgr.callbacks, token) + + mgr.Unregister(token) + require.NotContains(t, mgr.callbacks, token) + require.Equal(t, Token(2), mgr.nextCbID) + + mgr.Unregister(0) + require.NotContains(t, mgr.callbacks, token) + require.Equal(t, Token(2), mgr.nextCbID) + + mgr.Unregister(-1) + require.NotContains(t, mgr.callbacks, token) + require.Equal(t, Token(2), mgr.nextCbID) + + mgr.Unregister(99999999) + require.NotContains(t, mgr.callbacks, token) + require.Equal(t, Token(2), mgr.nextCbID) +} + +func Test_Call(t *testing.T) { + mgr := NewManager() + c := &testCallback{} + token := mgr.Register(c) + c.requireEqual(t, 0, "") + + ret := mgr.Call(token, "arg1") + require.Equal(t, ret, "ret-arg1") + c.requireEqual(t, 1, "arg1") + + ret = mgr.Call(-1, "arg1") + require.Empty(t, ret) + c.requireEqual(t, 1, "arg1") // No change + + ret = mgr.Call(token, "arg2") + require.Equal(t, ret, "ret-arg2") + c.requireEqual(t, 2, "arg2") + + ret = mgr.Call(99999999, "arg3") + require.Empty(t, ret) + c.requireEqual(t, 2, "arg2") // No change +} + +func Test_ConcurrentRegister(t *testing.T) { + const numTokens = 1000 + + mgr := NewManager() + var wg sync.WaitGroup + + tokens := make([]Token, numTokens) + wg.Add(numTokens) + for i := 0; i < numTokens; i++ { + go func(i int) { + defer wg.Done() + tokens[i] = mgr.Register(&testCallback{}) + require.Greater(t, tokens[i], 0) + }(i) + } + wg.Wait() + + require.Len(t, mgr.callbacks, numTokens) + require.Equal(t, Token(numTokens+1), mgr.nextCbID) + tokenSet := make(map[Token]bool) + for _, token := range tokens { + require.False(t, tokenSet[token], "Duplicate token found: %s", token) + tokenSet[token] = true + require.Contains(t, mgr.callbacks, token) + } +} + +func Test_ConcurrentCall(t *testing.T) { + const numInvocations = 1000 + + mgr := NewManager() + c := &testCallback{} + token := mgr.Register(c) + + var wg sync.WaitGroup + wg.Add(numInvocations) + for i := 0; i < numInvocations; i++ { + go func(i int) { + defer wg.Done() + ret := mgr.Call(token, fmt.Sprintf("data-%d", i)) + require.Equal(t, ret, fmt.Sprintf("ret-data-%d", i)) + }(i) + } + wg.Wait() + + require.Equal(t, int32(numInvocations), c.cnt.Load()) + require.Regexp(t, `^data-\d+$`, c.lastData.Load()) + + require.Len(t, mgr.callbacks, 1) + require.Equal(t, Token(2), mgr.nextCbID) +} + +func Test_ConcurrentUnregister(t *testing.T) { + const ( + numTokens = 50 + numDeletes = 1000 + ) + + mgr := NewManager() + tokens := make([]Token, numTokens) + for i := 0; i < numTokens; i++ { + tokens[i] = mgr.Register(&testCallback{}) + } + require.Len(t, mgr.callbacks, numTokens) + require.Equal(t, Token(numTokens+1), mgr.nextCbID) + + var wg sync.WaitGroup + wg.Add(numDeletes) + for i := 0; i < numDeletes; i++ { + go func(i int) { + defer wg.Done() + mgr.Unregister(tokens[i%numTokens]) + }(i) + } + wg.Wait() + + require.Len(t, mgr.callbacks, 0) + require.Equal(t, Token(numTokens+1), mgr.nextCbID) +} + +// testCallback is a mock implementation of callback.Callback for testing. +type testCallback struct { + cnt atomic.Int32 + lastData atomic.Value +} + +func (tc *testCallback) OnCall(data string) string { + tc.cnt.Add(1) + tc.lastData.Store(data) + return fmt.Sprintf("ret-%s", data) +} + +func (tc *testCallback) requireEqual(t *testing.T, cnt int32, data string) { + require.Equal(t, cnt, tc.cnt.Load()) + if cnt == 0 { + require.Nil(t, tc.lastData.Load()) + } else { + require.Equal(t, data, tc.lastData.Load()) + } +} diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go index ea66d07d878..3f048b3752f 100644 --- a/client/go/outline/electron/go_plugin.go +++ b/client/go/outline/electron/go_plugin.go @@ -29,6 +29,22 @@ typedef struct InvokeMethodResult_t // This error can be parsed by the PlatformError in TypeScript. const char *ErrorJson; } InvokeMethodResult; + +// CallbackFuncPtr is a C function pointer type that represents a callback function. +// This callback function will be invoked by the Go side. +// +// - data: The callback data, passed as a C string (typically a JSON string). +typedef const char* (*CallbackFuncPtr)(const char *data); + +// InvokeCallback is a helper function that invokes the C callback function pointer. +// +// This function serves as a bridge, allowing Go to call a C function pointer. +// +// - f: The C function pointer to be invoked. +// - data: A C-string typed data to be passed to the callback. +static const char* InvokeCallback(CallbackFuncPtr f, const char *data) { + return f(data); +} */ import "C" import ( @@ -38,6 +54,7 @@ import ( "unsafe" "github.com/Jigsaw-Code/outline-apps/client/go/outline" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/callback" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" ) @@ -57,6 +74,39 @@ func InvokeMethod(method *C.char, input *C.char) C.InvokeMethodResult { } } +// cgoCallback implements the [callback.Handler] interface and bridges the Go callback +// to a C function pointer. +type cgoCallback struct { + ptr C.CallbackFuncPtr +} + +var _ callback.Handler = (*cgoCallback)(nil) + +// OnCall forwards the data to the C callback function pointer. +func (ccb *cgoCallback) OnCall(data string) string { + ret := C.InvokeCallback(ccb.ptr, newCGoString(data)) + return C.GoString(ret) +} + +// RegisterCallback registers a new callback function with the [callback.DefaultManager] +// and returns a [callback.Token] number. +// +// The caller can delete the callback by calling [UnregisterCallback] with the returned token. +// +//export RegisterCallback +func RegisterCallback(cb C.CallbackFuncPtr) C.int { + token := callback.DefaultManager().Register(&cgoCallback{cb}) + return C.int(token) +} + +// UnregisterCallback unregisters the callback from the [callback.DefaultManager] +// identified by the token returned by [RegisterCallback]. +// +//export UnregisterCallback +func UnregisterCallback(token C.int) { + callback.DefaultManager().Unregister(callback.Token(token)) +} + // newCGoString allocates memory for a C string based on the given Go string. // It should be paired with [FreeCGoString] to avoid memory leaks. func newCGoString(s string) *C.char { diff --git a/client/go/outline/method_channel.go b/client/go/outline/method_channel.go index 09de961cfa8..8cf1b24b798 100644 --- a/client/go/outline/method_channel.go +++ b/client/go/outline/method_channel.go @@ -44,6 +44,16 @@ const ( // - Input: the transport config text // - Output: the TunnelConfigJson that Typescript needs MethodParseTunnelConfig = "ParseTunnelConfig" + + // SetVPNStateChangeListener sets a callback to be invoked when the VPN state changes. + // + // We recommend the caller to set this listener at app startup to catch all VPN state changes. + // Users might start the VPN from system settings, bypassing the app; + // so setting the listener when connecting within the app might miss some events. + // + // - Input: A callback token string. + // - Output: null + MethodSetVPNStateChangeListener = "SetVPNStateChangeListener" ) // InvokeMethodResult represents the result of an InvokeMethod call. @@ -80,6 +90,12 @@ func InvokeMethod(method string, input string) *InvokeMethodResult { case MethodParseTunnelConfig: return doParseTunnelConfig(input) + case MethodSetVPNStateChangeListener: + err := setVPNStateChangeListener(input) + return &InvokeMethodResult{ + Error: platerrors.ToPlatformError(err), + } + default: return &InvokeMethodResult{Error: &platerrors.PlatformError{ Code: platerrors.InternalError, diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go index acf35e99cae..9ee40113dd2 100644 --- a/client/go/outline/vpn/vpn.go +++ b/client/go/outline/vpn/vpn.go @@ -16,10 +16,12 @@ package vpn import ( "context" + "encoding/json" "io" "log/slog" "sync" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/callback" "github.com/Jigsaw-Code/outline-sdk/transport" ) @@ -47,9 +49,20 @@ type platformVPNConn interface { Close() error } +// ConnectionStatus represents the status of a [VPNConnection]. +type ConnectionStatus string + +const ( + ConnectionConnected ConnectionStatus = "Connected" + ConnectionDisconnected ConnectionStatus = "Disconnected" + ConnectionConnecting ConnectionStatus = "Connecting" + ConnectionDisconnecting ConnectionStatus = "Disconnecting" +) + // VPNConnection represents a system-wide VPN connection. type VPNConnection struct { - ID string + ID string `json:"id"` + Status ConnectionStatus `json:"status"` cancelEst context.CancelFunc wgEst, wgCopy sync.WaitGroup @@ -62,6 +75,24 @@ type VPNConnection struct { // This package allows at most one active VPN connection at the same time. var mu sync.Mutex var conn *VPNConnection +var stateChangeCb callback.Token + +// SetStatus sets the [VPNConnection] Status and calls the stateChangeCb callback. +func (c *VPNConnection) SetStatus(status ConnectionStatus) { + c.Status = status + if connJson, err := json.Marshal(c); err == nil { + callback.DefaultManager().Call(stateChangeCb, string(connJson)) + } else { + slog.Warn("failed to marshal VPN connection", "err", err) + } +} + +// SetStateChangeListener sets the given [callback.Token] as a global VPN connection +// state change listener. +// The token should have already been registered with the [callback.DefaultManager]. +func SetStateChangeListener(token callback.Token) { + stateChangeCb = token +} // EstablishVPN establishes a new active [VPNConnection] connecting to a [ProxyDevice] // with the given VPN [Config]. @@ -81,7 +112,7 @@ func EstablishVPN( panic("a PacketListener must be provided") } - c := &VPNConnection{ID: conf.ID} + c := &VPNConnection{ID: conf.ID, Status: ConnectionDisconnected} ctx, c.cancelEst = context.WithCancel(ctx) if c.platform, err = newPlatformVPNConn(conf); err != nil { @@ -97,6 +128,14 @@ func EstablishVPN( } slog.Debug("establishing vpn connection ...", "id", c.ID) + c.SetStatus(ConnectionConnecting) + defer func() { + if err == nil { + c.SetStatus(ConnectionConnected) + } else { + c.SetStatus(ConnectionDisconnected) + } + }() if c.proxy, err = ConnectRemoteDevice(ctx, sd, pl); err != nil { slog.Error("failed to connect to the remote device", "err", err) @@ -154,15 +193,16 @@ func closeVPNNoLock() (err error) { return nil } + slog.Debug("terminating the global vpn connection...", "id", conn.ID) + conn.SetStatus(ConnectionDisconnecting) defer func() { if err == nil { slog.Info("vpn connection terminated", "id", conn.ID) + conn.SetStatus(ConnectionDisconnected) conn = nil } }() - slog.Debug("terminating the global vpn connection...", "id", conn.ID) - // Cancel the Establish process and wait conn.cancelEst() conn.wgEst.Wait() diff --git a/client/go/outline/vpn_linux.go b/client/go/outline/vpn_linux.go index d9b839b51be..5bec9bd8dc4 100644 --- a/client/go/outline/vpn_linux.go +++ b/client/go/outline/vpn_linux.go @@ -17,7 +17,9 @@ package outline import ( "context" "encoding/json" + "strconv" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/callback" perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/Jigsaw-Code/outline-apps/client/go/outline/vpn" ) @@ -57,3 +59,16 @@ func establishVPN(configStr string) error { func closeVPN() error { return vpn.CloseVPN() } + +func setVPNStateChangeListener(cbTokenStr string) error { + cbToken, err := strconv.Atoi(cbTokenStr) + if err != nil { + return perrs.PlatformError{ + Code: perrs.InternalError, + Message: "invalid callback token", + Cause: perrs.ToPlatformError(err), + } + } + vpn.SetStateChangeListener(callback.Token(cbToken)) + return nil +} diff --git a/client/go/outline/vpn_others.go b/client/go/outline/vpn_others.go index 7e0c17f4a43..08eddac9d2d 100644 --- a/client/go/outline/vpn_others.go +++ b/client/go/outline/vpn_others.go @@ -18,5 +18,6 @@ package outline import "errors" -func establishVPN(configStr string) error { return errors.ErrUnsupported } -func closeVPN() error { return errors.ErrUnsupported } +func establishVPN(configStr string) error { return errors.ErrUnsupported } +func closeVPN() error { return errors.ErrUnsupported } +func setVPNStateChangeListener(cbTokenStr string) error { return errors.ErrUnsupported }