Skip to content

Commit

Permalink
Use the new callback in TypeScript
Browse files Browse the repository at this point in the history
  • Loading branch information
jyyi1 committed Jan 22, 2025
1 parent d653dce commit 5a24569
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 122 deletions.
136 changes: 53 additions & 83 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,68 +38,76 @@ export async function invokeGoMethod(
const result = await ensureCgo().invokeMethod(method, input);
console.debug(`[Backend] - InvokeMethod "${method}" returned`, result);
if (result.ErrorJson) {
throw Error(result.ErrorJson);
throw new Error(result.ErrorJson);
}
return result.Output;
}

/**
* Represents a callback function for an event.
* @param data The event data string passed from the source.
* @param param The param string provided during registration.
* Represents a function that will be called from the Go backend.
* @param data The data string passed from the Go backend.
*/
export type CallbackFunction = (data: string, param: string) => void;
export type CallbackFunction = (data: string) => void;

/**
* Subscribes to an event from the Go backend.
* Koffi requires us to register all persistent callbacks; we track the registrations here.
* @see https://koffi.dev/callbacks#registered-callbacks
*/
const koffiCallbacks = new Map<string, koffi.IKoffiRegisteredCallback>();

/**
* Registers a callback function in TypeScript, making it invokable from Go.
*
* @param name The name of the event to subscribe to.
* @param callback The callback function to be called when the event is fired.
* @param param An optional parameter to be passed to the callback function.
* @returns A Promise that resolves when the subscription is successful.
* The caller can delete the callback by calling `deleteCallback`.
*
* @remarks Subscribing to an event will replace any previously subscribed callback for that event.
* @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 subscribeEvent(
name: string,
callback: CallbackFunction,
param: string | null = null
): Promise<void> {
console.debug(`[Backend] - calling SubscribeEvent "${name}" "${param}" ...`);
await ensureCgo().subscribeEvent(
name,
registerKoffiCallback(name, callback),
param
);
console.debug(`[Backend] - SubscribeEvent "${name}" done`);
export async function newCallback(callback: CallbackFunction): Promise<string> {
console.debug(`[Backend] - calling newCallback ...`);
const persistentCallback = koffi.register(callback, ensureCgo().callbackFuncPtr);
const result = await ensureCgo().newCallback(persistentCallback);
console.debug(`[Backend] - newCallback done`, result);
if (result.ErrorJson) {
koffi.unregister(persistentCallback);
throw new Error(result.ErrorJson);
}
koffiCallbacks.set(result.Output, persistentCallback);
console.debug(`[Backend] - registered persistent callback ${result.Output} with koffi`);
return result.Output;
}

/**
* Unsubscribes from an event from the Go backend.
* Unregisters a specified callback function from the Go backend.
*
* @param name The name of the event to unsubscribe from.
* @returns A Promise that resolves when the unsubscription is successful.
* @param token The callback token returned from `newCallback`.
* @returns A Promise that resolves when the unregistration is done.
*/
export async function unsubscribeEvent(name: string): Promise<void> {
console.debug(`[Backend] - calling UnsubscribeEvent "${name}" ...`);
await ensureCgo().unsubscribeEvent(name);
unregisterKoffiCallback(name);
console.debug(`[Backend] - UnsubscribeEvent "${name}" done`);
export async function deleteCallback(token: string): Promise<void> {
console.debug(`[Backend] - calling deleteCallback ...`);
await ensureCgo().deleteCallback(token);
console.debug(`[Backend] - deleteCallback done`);
const persistentCallback = koffiCallbacks.get(token);
if (persistentCallback) {
koffi.unregister(persistentCallback);
koffiCallbacks.delete(token);
console.debug(`[Backend] - unregistered persistent callback ${token} from koffi`)
}
}

/** Interface containing the exported native CGo functions. */
interface CgoFunctions {
// InvokeMethodResult InvokeMethod(char* method, char* input);
invokeMethod: Function;

// void (*ListenerFunc)(const char *data, const char *param);
listenerFuncPtr: koffi.IKoffiCType;
// void (*CallbackFuncPtr)(const char *data);
callbackFuncPtr: koffi.IKoffiCType;

// void SubscribeEvent(char* eventName, ListenerFunc callback, char* param);
subscribeEvent: Function;
// InvokeMethodResult NewCallback(CallbackFuncPtr cb);
newCallback: Function;

// void UnsubscribeEvent(char* eventName);
unsubscribeEvent: Function;
// void DeleteCallback(char* token);
deleteCallback: Function;
}

/** Singleton of the loaded native CGo functions. */
Expand Down Expand Up @@ -131,59 +139,21 @@ function ensureCgo(): CgoFunctions {
backendLib.func('InvokeMethod', invokeMethodResult, ['str', 'str']).async
);

// Define SubscribeEvent/UnsubscribeEvent data structures and function
const listenerFuncPtr = koffi.pointer(
koffi.proto('ListenerFunc', 'void', [cgoString, cgoString])
// Define callback data structures and functions
const callbackFuncPtr = koffi.pointer(
koffi.proto('CallbackFuncPtr', 'void', [cgoString])
);
const subscribeEvent = promisify(
backendLib.func('SubscribeEvent', 'void', ['str', listenerFuncPtr, 'str'])
const newCallback = promisify(
backendLib.func('NewCallback', invokeMethodResult, [callbackFuncPtr])
.async
);
const unsubscribeEvent = promisify(
backendLib.func('UnsubscribeEvent', 'void', ['str']).async
const deleteCallback = promisify(
backendLib.func('DeleteCallback', 'void', ['str']).async
);

// Cache them so we don't have to reload these functions
cgo = {invokeMethod, listenerFuncPtr, subscribeEvent, unsubscribeEvent};
cgo = {invokeMethod, callbackFuncPtr, newCallback, deleteCallback};
console.debug('[Backend] - cgo environment initialized');
}
return cgo;
}

//#region Koffi's internal registration management

const koffiCallbacks = new Map<string, koffi.IKoffiRegisteredCallback>();

/**
* Registers a persistent JS callback function with Koffi.
* This will replace any previously registered functions to align with `subscribeEvent`.
*
* @param eventName The name of the event.
* @param jsCallback The JavaScript callback function.
* @returns The registered Koffi callback.
* @see https://koffi.dev/callbacks#registered-callbacks
*/
function registerKoffiCallback(
eventName: string,
jsCallback: CallbackFunction
): koffi.IKoffiRegisteredCallback {
unregisterKoffiCallback(eventName);
const koffiCb = koffi.register(jsCallback, ensureCgo().listenerFuncPtr);
koffiCallbacks.set(eventName, koffiCb);
return koffiCb;
}

/**
* Unregisters a Koffi callback for a specific event.
*
* @param eventName The name of the event.
*/
function unregisterKoffiCallback(eventName: string): void {
const cb = koffiCallbacks.get(eventName);
if (cb) {
koffi.unregister(cb);
koffiCallbacks.delete(eventName);
}
}

//#endregion Koffi's internal registration management
39 changes: 14 additions & 25 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, newCallback} from './go_plugin';
import {
StartRequestJson,
TunnelStatus,
Expand All @@ -36,7 +36,7 @@ interface EstablishVpnRequest {
}

export async function establishVpn(request: StartRequestJson) {
subscribeVPNConnEvents();
await subscribeVPNConnEvents();

const config: EstablishVpnRequest = {
vpn: {
Expand Down Expand Up @@ -68,11 +68,11 @@ export async function establishVpn(request: StartRequestJson) {
transport: request.config.transport,
};

await invokeMethod('EstablishVPN', JSON.stringify(config));
await invokeGoMethod('EstablishVPN', JSON.stringify(config));
}

export async function closeVpn(): Promise<void> {
await invokeMethod('CloseVPN', '');
await invokeGoMethod('CloseVPN', '');
}

export type VpnStatusCallback = (id: string, status: TunnelStatus) => void;
Expand All @@ -88,7 +88,7 @@ export function onVpnStatusChanged(cb: VpnStatusCallback): void {
* @param connJson The JSON string representing the VPNConn interface.
*/
function handleVpnConnectionStatusChanged(connJson: string) {
const conn = parseVPNConnJSON(connJson);
const conn = JSON.parse(connJson) as VPNConn;
console.debug(`received ${StatusChangedEvent}`, conn);
switch (conn?.status) {
case VPNConnConnected:
Expand All @@ -106,16 +106,20 @@ function handleVpnConnectionStatusChanged(connJson: string) {
}
}

let vpnEventsSubscribed = false;
// Callback token of the VPNConnStatusChanged event
let vpnConnStatusChangedCb: string | undefined;

/**
* Subscribes to all VPN connection related events.
* This function ensures that the subscription only happens once.
*/
function subscribeVPNConnEvents(): void {
if (!vpnEventsSubscribed) {
subscribeEvent(StatusChangedEvent, handleVpnConnectionStatusChanged);
vpnEventsSubscribed = true;
async function subscribeVPNConnEvents(): Promise<void> {
if (!vpnConnStatusChangedCb) {
vpnConnStatusChangedCb = await newCallback(handleVpnConnectionStatusChanged);
await invokeGoMethod('AddEventListener', JSON.stringify({
name: StatusChangedEvent,
callbackToken: vpnConnStatusChangedCb,
}));
}
}

Expand All @@ -137,19 +141,4 @@ interface VPNConn {
readonly status: VPNConnStatus;
}

function parseVPNConnJSON(json: string): VPNConn | null {
try {
const rawConn = JSON.parse(json);
if (!('id' in rawConn) || !rawConn.id || !('status' in rawConn)) {
return null;
}
return {
id: rawConn.id,
status: rawConn.status,
};
} catch {
return null;
}
}

//#endregion type definitions of VPNConnection in Go
9 changes: 8 additions & 1 deletion client/go/outline/electron/go_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,21 @@ func (ccb *cgoCallback) OnCall(data string) {

// NewCallback registers a new callback function and returns a [callback.Token] string.
//
// The caller can delete the callback by calling [InvokeMethod] with method "DeleteCallback".
// The caller can delete the callback by calling [DeleteCallback] with the returned token.
//
//export NewCallback
func NewCallback(cb C.CallbackFuncPtr) C.InvokeMethodResult {
token := callback.New(&cgoCallback{cb})
return C.InvokeMethodResult{Output: newCGoString(string(token))}
}

// DeleteCallback deletes the callback identified by the token returned by [NewCallback].
//
//export DeleteCallback
func DeleteCallback(token *C.char) {
callback.Delete(callback.Token(C.GoString(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 {
Expand Down
62 changes: 62 additions & 0 deletions client/go/outline/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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 outline

import (
"encoding/json"

"github.com/Jigsaw-Code/outline-apps/client/go/outline/callback"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/event"
perrs "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
)

// eventListenerJSON represents the JSON structure for adding or removing event listeners.
type eventListenerJSON struct {
// Name is the name of the event to listen for.
Name string `json:"name"`

// Callback token is the token of the callback function returned from callback.NewCallback.
Callback string `json:"callbackToken"`
}

func addEventListener(input string) error {
listener, err := parseEventListenerInput(input)
if err != nil {
return err
}
event.AddListener(event.EventName(listener.Name), callback.Token(listener.Callback))
return nil
}

func removeEventListener(input string) error {
listener, err := parseEventListenerInput(input)
if err != nil {
return err
}
event.RemoveListener(event.EventName(listener.Name), callback.Token(listener.Callback))
return nil
}

func parseEventListenerInput(input string) (*eventListenerJSON, error) {
var listener eventListenerJSON
if err := json.Unmarshal([]byte(input), &listener); err != nil {
return nil, perrs.PlatformError{
Code: perrs.InternalError,
Message: "invalid event listener argument",
Cause: perrs.ToPlatformError(err),
}
}
return &listener, nil
}
Loading

0 comments on commit 5a24569

Please sign in to comment.