Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): add callback/event mechanism between TypeScript and Go #2330

Merged
merged 28 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
873296c
feat(client): add callback/event mechanism between TypeScript and Go
jyyi1 Jan 15, 2025
12cb5f5
Add corresponding cgo code to TS
jyyi1 Jan 15, 2025
5031d15
Merge branch 'master' into junyi/vpn-callback-linux
jyyi1 Jan 16, 2025
af81acc
Expose C functions to subscribe/unsubcribe events
jyyi1 Jan 16, 2025
15e1735
raise and subscribe to VPN connection status change event
jyyi1 Jan 16, 2025
4a2c58d
Simplify parseVPNConnJSON
jyyi1 Jan 16, 2025
3ced93e
decouple callback from event
jyyi1 Jan 18, 2025
cd3129d
expose new callback function in electron Go plugin
jyyi1 Jan 21, 2025
d653dce
Merge branch 'master' into junyi/vpn-callback-linux
jyyi1 Jan 21, 2025
5a24569
Use the new callback in TypeScript
jyyi1 Jan 22, 2025
055c1e3
test latest go for CI
jyyi1 Jan 22, 2025
22ebf3c
try gnu clib
jyyi1 Jan 22, 2025
8c4ffac
more debug for zig
jyyi1 Jan 23, 2025
d72486b
Try newer action runner
jyyi1 Jan 23, 2025
4429a10
try mingw
jyyi1 Jan 23, 2025
93496cb
revert the CI job changes and clear cache
jyyi1 Jan 23, 2025
f33903e
add callback Token type to TypeScript
jyyi1 Jan 23, 2025
2e126df
fix linting errors
jyyi1 Jan 23, 2025
dab470e
Merge branch 'master' into junyi/vpn-callback-linux
jyyi1 Jan 24, 2025
5080879
Use callback Token directly
jyyi1 Jan 27, 2025
4e3e120
refactor VPN status changed event
jyyi1 Jan 27, 2025
b8e738f
Fix linting
jyyi1 Jan 27, 2025
a3de633
remove event package, replace it directly with callback
jyyi1 Jan 31, 2025
535d0c4
fix koffi async call
jyyi1 Jan 31, 2025
64bd661
Merge branch 'master' into junyi/vpn-callback-linux
jyyi1 Jan 31, 2025
75d5a45
resolve conflicts
jyyi1 Jan 31, 2025
ca2b263
update comment
jyyi1 Jan 31, 2025
c46457e
Merge branch 'master' into junyi/vpn-callback-linux
jyyi1 Jan 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading