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

refactor(client): create Typescript MethodChannel on all platforms #2298

Merged
merged 19 commits into from
Dec 4, 2024
28 changes: 13 additions & 15 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,12 @@ import koffi from 'koffi';

import {pathToBackendLibrary} from './app_paths';

let invokeGoAPIFunc: Function | undefined;

export type GoApiName = 'FetchResource';
let invokeMethodFunc: Function | undefined;

/**
* Calls a Go function by invoking the `InvokeGoAPI` function in the native backend library.
* Calls a Go function by invoking the `InvokeMethod` function in the native backend library.
*
* @param api The name of the Go API to invoke.
* @param method The name of the Go method to invoke.
* @param input The input string to pass to the API.
* @returns A Promise that resolves to the output string returned by the API.
* @throws An Error containing PlatformError details if the API call fails.
Expand All @@ -34,11 +32,11 @@ export type GoApiName = 'FetchResource';
* Ensure that the function signature and data structures are consistent with the C definitions
* in `./client/go/outline/electron/go_plugin.go`.
*/
export async function invokeGoApi(
api: GoApiName,
export async function invokeMethod(
method: string,
input: string
): Promise<string> {
if (!invokeGoAPIFunc) {
if (!invokeMethodFunc) {
const backendLib = koffi.load(pathToBackendLibrary());

// Define C strings and setup auto release
Expand All @@ -48,19 +46,19 @@ export async function invokeGoApi(
backendLib.func('FreeCGoString', 'void', ['str'])
);

// Define InvokeGoAPI data structures and function
const invokeGoApiResult = koffi.struct('InvokeGoAPIResult', {
// Define InvokeMethod data structures and function
const invokeGoApiResult = koffi.struct('InvokeMethodResult', {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
Output: cgoString,
ErrorJson: cgoString,
});
invokeGoAPIFunc = promisify(
backendLib.func('InvokeGoAPI', invokeGoApiResult, ['str', 'str']).async
invokeMethodFunc = promisify(
backendLib.func('InvokeMethod', invokeGoApiResult, ['str', 'str']).async
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
);
}

console.debug('[Backend] - calling InvokeGoAPI ...');
const result = await invokeGoAPIFunc(api, input);
console.debug('[Backend] - InvokeGoAPI returned', result);
console.debug('[Backend] - calling InvokeMethod ...');
const result = await invokeMethodFunc(method, input);
console.debug('[Backend] - InvokeMethod returned', result);
if (result.ErrorJson) {
throw Error(result.ErrorJson);
}
Expand Down
16 changes: 8 additions & 8 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
import {autoUpdater} from 'electron-updater';

import {lookupIp} from './connectivity';
import {GoApiName, invokeGoApi} from './go_plugin';
import {invokeMethod} from './go_plugin';
import {GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
import {TunnelStore} from './tunnel_store';
Expand Down Expand Up @@ -499,19 +499,19 @@ function main() {
mainWindow?.webContents.send('outline-ipc-push-clipboard');
});

// This IPC handler allows the renderer process to call Go API functions exposed by the backend.
// This IPC handler allows the renderer process to call functions exposed by the backend.
// It takes two arguments:
// - api: The name of the Go API function to call.
// - input: A string representing the input data to the Go function.
// - method: The name of the method to call.
// - params: A string representing the input data to the function.
//
// The handler returns the output string from the Go function if successful.
// Both the input string and output string need to be interpreted by the renderer process according
// to the specific API being called.
// If Go function encounters an error, it throws an Error that can be parsed by the `PlatformError`.
// If the function encounters an error, it throws an Error that can be parsed by the `PlatformError`.
ipcMain.handle(
'outline-ipc-invoke-go-api',
(_, api: GoApiName, input: string): Promise<string> =>
invokeGoApi(api, input)
'outline-ipc-invoke-method',
async (_, method: string, params: string): Promise<string> =>
await invokeMethod(method, params)
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
);

// Connects to a proxy server specified by a config.
Expand Down
41 changes: 10 additions & 31 deletions client/go/outline/electron/go_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ package main
/*
#include <stdlib.h> // for C.free

// InvokeGoAPIResult is a struct used to pass result from Go to TypeScript boundary.
typedef struct InvokeGoAPIResult_t
// InvokeMethodResult is a struct used to pass result from Go to TypeScript boundary.
typedef struct InvokeMethodResult_t
{
// A string representing the result of the Go function call.
// This may be a raw string or a JSON string depending on the API call.
Expand All @@ -28,7 +28,7 @@ typedef struct InvokeGoAPIResult_t
// Go function call, or NULL if no error occurred.
// This error can be parsed by the PlatformError in TypeScript.
const char *ErrorJson;
} InvokeGoAPIResult;
} InvokeMethodResult;
*/
import "C"
import (
Expand All @@ -41,40 +41,19 @@ import (
"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
)

// API name constants
const (
// FetchResourceAPI fetches a resource located at a given URL.
//
// - Input: the URL string of the resource to fetch
// - Output: the content in raw string of the fetched resource
FetchResourceAPI = "FetchResource"
)

// InvokeGoAPI is the unified entry point for TypeScript to invoke various Go functions.
// InvokeMethod is the unified entry point for TypeScript to invoke various Go functions.
//
// The input and output are all defined as string, but they may represent either a raw string,
// or a JSON string depending on the API call.
//
// Check the API name constants comment for more details about the input and output format.
//
//export InvokeGoAPI
func InvokeGoAPI(api *C.char, input *C.char) C.InvokeGoAPIResult {
apiName := C.GoString(api)
switch apiName {

case FetchResourceAPI:
res := outline.FetchResource(C.GoString(input))
return C.InvokeGoAPIResult{
Output: newCGoString(res.Content),
ErrorJson: marshalCGoErrorJson(platerrors.ToPlatformError(res.Error)),
}

default:
err := &platerrors.PlatformError{
Code: platerrors.InternalError,
Message: fmt.Sprintf("unsupported Go API: %s", apiName),
}
return C.InvokeGoAPIResult{ErrorJson: marshalCGoErrorJson(err)}
//export InvokeMethod
func InvokeMethod(method *C.char, input *C.char) C.InvokeMethodResult {
result := outline.InvokeMethod(C.GoString(method), C.GoString(input))
return C.InvokeMethodResult{
Output: newCGoString(result.Value),
ErrorJson: marshalCGoErrorJson(result.Error),
}
}

Expand Down
57 changes: 57 additions & 0 deletions client/go/outline/method_channel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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.

package outline

import (
"fmt"

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

// API name constants
const (
// FetchResourceMethod fetches a resource located at a given URL.
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
//
// - Input: the URL string of the resource to fetch
// - Output: the content in raw string of the fetched resource
MethodFetchResource = "FetchResource"
)

// InvokeMethodResult represents the result of an InvokeMethod call.
//
// We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes.
type InvokeMethodResult struct {
Value string
Error *platerrors.PlatformError
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
}

// InvokeMethod calls a method by name.
func InvokeMethod(method string, input string) *InvokeMethodResult {
switch method {
case MethodFetchResource:
url := input
result := FetchResource(url)
return &InvokeMethodResult{
Value: result.Content,
Error: result.Error,
}

default:
return &InvokeMethodResult{Error: &platerrors.PlatformError{
Code: platerrors.InternalError,
Message: fmt.Sprintf("unsupported Go method: %s", method),
}}
}
}
30 changes: 23 additions & 7 deletions client/src/www/app/main.cordova.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ import * as Sentry from '@sentry/browser';
import {AbstractClipboard} from './clipboard';
import {EnvironmentVariables} from './environment';
import {main} from './main';
import {installDefaultMethodChannel, MethodChannel} from './method_channel';
import {VpnApi} from './outline_server_repository/vpn';
import {CordovaVpnApi} from './outline_server_repository/vpn.cordova';
import {OutlinePlatform} from './platform';
import {OUTLINE_PLUGIN_NAME, pluginExec} from './plugin.cordova';
import {ResourceFetcher} from './resource_fetcher';
import {CordovaResourceFetcher} from './resource_fetcher.cordova';
import {
OUTLINE_PLUGIN_NAME,
pluginExec,
pluginExecWithErrorCode,
} from './plugin.cordova';
import {AbstractUpdater} from './updater';
import * as interceptors from './url_interceptor';
import {NoOpVpnInstaller, VpnInstaller} from './vpn_installer';
Expand Down Expand Up @@ -71,6 +74,22 @@ class CordovaErrorReporter extends SentryErrorReporter {
}
}

class CordovaMethodChannel implements MethodChannel {
async invokeMethod(methodName: string, params: string): Promise<string> {
switch (methodName) {
case 'FetchResource':
// TODO(fortuna): wire generic calls in the Cordova plugin.
return await pluginExecWithErrorCode<string>('fetchResource', params);
default:
return await pluginExecWithErrorCode(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NOT THIS PR] I feel the name pluginExecWithErrorCode can be updated, instead of returning error codes, it throws a PlatformError now.

'invokeMethod',
methodName,
params
);
}
}
}

// This class should only be instantiated after Cordova fires the deviceready event.
class CordovaPlatform implements OutlinePlatform {
getVpnApi(): VpnApi | undefined {
Expand Down Expand Up @@ -117,10 +136,6 @@ class CordovaPlatform implements OutlinePlatform {
return new NoOpVpnInstaller();
}

getResourceFetcher(): ResourceFetcher {
return new CordovaResourceFetcher();
}

quitApplication() {
// Only used in macOS because menu bar apps provide no alternative way of quitting.
cordova.exec(
Expand Down Expand Up @@ -148,5 +163,6 @@ window.handleOpenURL = (url: string) => {
};

onceDeviceReady.then(() => {
installDefaultMethodChannel(new CordovaMethodChannel());
main(new CordovaPlatform());
});
18 changes: 16 additions & 2 deletions client/src/www/app/main.electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import * as Sentry from '@sentry/electron/renderer';

import {AbstractClipboard} from './clipboard';
import {getLocalizationFunction, main} from './main';
import {installDefaultMethodChannel, MethodChannel} from './method_channel';
import {VpnApi} from './outline_server_repository/vpn';
import {ElectronVpnApi} from './outline_server_repository/vpn.electron';
import {ElectronResourceFetcher} from './resource_fetcher.electron';
import {AbstractUpdater} from './updater';
import {UrlInterceptor} from './url_interceptor';
import {VpnInstaller} from './vpn_installer';
Expand Down Expand Up @@ -127,6 +127,21 @@ class ElectronErrorReporter implements OutlineErrorReporter {
}
}

class ElectronMethodChannel implements MethodChannel {
async invokeMethod(methodName: string, params: string): Promise<string> {
switch (methodName) {
default:
return await window.electron.methodChannel.invoke(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the await (and the async above) is not needed here? IIRC, methodChannel.invoke should already return a Promise<string>.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's clearer if we call await. Otherwise it reads as if we are returning a promise of a promise, even though Typescript auto-unwraps it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not recommend return invoke(...) in an async function. Instead, I would maybe prefer return invoke(...) in a regular function, since we are not doing any multiple asynchronous steps here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. But I also removed the switch to avoid mistakes. Previously I was implementing an interception there, and I needed the await.

'invoke-method',
methodName,
params
);
}
}
}

installDefaultMethodChannel(new ElectronMethodChannel());

main({
getVpnApi(): VpnApi | undefined {
if (isOsSupported) {
Expand All @@ -138,6 +153,5 @@ main({
getErrorReporter: _ => new ElectronErrorReporter(),
getUpdater: () => new ElectronUpdater(),
getVpnServiceInstaller: () => new ElectronVpnInstaller(),
getResourceFetcher: () => new ElectronResourceFetcher(),
quitApplication: () => window.electron.methodChannel.send('quit-app'),
});
7 changes: 2 additions & 5 deletions client/src/www/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
FakeVpnApi,
} from './outline_server_repository/vpn.fake';
import {OutlinePlatform} from './platform';
import {BrowserResourceFetcher} from './resource_fetcher';
import {Settings} from './settings';
import {EventQueue} from '../model/events';

Expand Down Expand Up @@ -61,8 +60,7 @@ function createServerRepo(platform: OutlinePlatform, eventQueue: EventQueue) {
vpnApi,
eventQueue,
window.localStorage,
localize,
platform.getResourceFetcher()
localize
);
}

Expand All @@ -71,8 +69,7 @@ function createServerRepo(platform: OutlinePlatform, eventQueue: EventQueue) {
new FakeVpnApi(),
eventQueue,
window.localStorage,
localize,
new BrowserResourceFetcher()
localize
);

if (repo.getAll().length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {pluginExecWithErrorCode} from './plugin.cordova';
import {ResourceFetcher} from './resource_fetcher';
export interface MethodChannel {
invokeMethod(methodName: string, params: string): Promise<string>;
}

let defaultMethodChannel: MethodChannel;

export function installDefaultMethodChannel(
methodChannel: MethodChannel
): void {
defaultMethodChannel = methodChannel;
}

/**
* Fetches resources using Cordova plugin.
*/
export class CordovaResourceFetcher implements ResourceFetcher {
fetch(url: string): Promise<string> {
return pluginExecWithErrorCode<string>('fetchResource', url);
export function getDefaultMethodChannel(): MethodChannel {
if (!defaultMethodChannel) {
throw new Error('default MethodChannel not installed');
}
return defaultMethodChannel;
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading