Skip to content

Commit

Permalink
feat(client): add callback/event mechanism between TypeScript and Go (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
jyyi1 authored Feb 1, 2025
1 parent 555d940 commit de32ebc
Show file tree
Hide file tree
Showing 10 changed files with 626 additions and 45 deletions.
165 changes: 147 additions & 18 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -36,31 +34,162 @@ export async function invokeGoMethod(
method: string,
input: string
): Promise<string> {
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<CallbackToken> {
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<void> {
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<CallbackToken> {
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<void> {
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;
}
15 changes: 8 additions & 7 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down
71 changes: 57 additions & 14 deletions client/electron/vpn_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
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
Loading

0 comments on commit de32ebc

Please sign in to comment.