SDA-3770 Add support for optional symphony-c9-shell (#1446)

* Add support for optional symphony-c9-shell
This commit is contained in:
Robin Westberg 2022-07-05 09:43:35 +02:00 committed by GitHub
parent 5977ed8642
commit 8a7d5c0fcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 501 additions and 9 deletions

View File

@ -13,7 +13,7 @@ namespace Symphony
void dialog_Load(object sender, System.EventArgs e)
{
// Detect if Symphony is running
bool isRunning = System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1;
bool isRunning = (System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1 || System.Diagnostics.Process.GetProcessesByName("C9Shell").Length >= 1);
if (isRunning)
{
// If it is running, disable the "next" button

View File

@ -12,7 +12,7 @@ namespace Symphony
private void MaintenanceDialog_Shown(object sender, System.EventArgs e)
{
// Detect if Symphony is running
bool isRunning = System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1;
bool isRunning = (System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1 || System.Diagnostics.Process.GetProcessesByName("C9Shell").Length >= 1);
if (isRunning)
{
// If it is running, continue to the "Close Symphony" screen

View File

@ -53,8 +53,8 @@ class Script
// StartOn = SvcEvent.Install,
// StopOn = SvcEvent.InstallUninstall_Wait,
// RemoveOn = SvcEvent.Uninstall_Wait,
// };
// };
// Create a wixsharp project instance and assign the project name to it, and a hierarchy of all files to include
// Files are taken from multiple locations, and not all files in each location should be included, which is why
// the file list is rather long and explicit. At some point we might make the `dist` folder match exactly the
@ -116,7 +116,9 @@ class Script
new Files(@"..\..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\*.*")
)
)
)
),
new Dir(@"cloud9",
new Files(@"..\..\..\dist\win-unpacked\cloud9\*.*")
),
// Add a launch condition to require Windows Server 2008 or later
@ -124,7 +126,7 @@ class Script
// https://docs.microsoft.com/en-us/windows/win32/msi/operating-system-property-values
new LaunchCondition("VersionNT>=600 AND WindowsBuild>=6001", "OS not supported"),
// Add registry entry used by protocol handler to launch symphony when opening symphony:// URIs
// Add registry entry used by protocol handler to launch symphony when opening symphony:// URIs
new RegValue(WixSharp.RegistryHive.ClassesRoot, productName, "", "URL:symphony"),
new RegValue(WixSharp.RegistryHive.ClassesRoot, productName, "URL Protocol", ""),
new RegValue(WixSharp.RegistryHive.ClassesRoot, productName + @"\shell\open\command", "", "\"[INSTALLDIR]Symphony.exe\" " + userDataPathArgument + " \"%1\""),
@ -133,7 +135,8 @@ class Script
// will not work for us, as we have a "minimize on close" option, which stops the app from terminating on WM_CLOSE. So we
// instruct the installer to not send a Close message, but instead send the EndSession message, and we have a custom event
// handler in the SDA code which listens for this message, and ensures app termination when it is received.
new CloseApplication("Symphony.exe", false) { EndSessionMessage = true }
new CloseApplication("Symphony.exe", false) { EndSessionMessage = true },
new CloseApplication("C9Shell.exe", false) { EndSessionMessage = true }
);
// The build script which calls the wix# builder, will be run from a command environment which has %SYMVER% set.
@ -312,6 +315,19 @@ class Script
}
}
});
// The embedded C9 Shell should terminate when the parent window is closed, but it doesn't always work. So we'll just force it to exit
System.Diagnostics.Process.GetProcessesByName("C9Shell").ForEach(p =>
{
if (System.IO.Path.GetFileName(p.MainModule.FileName) == "C9Shell.exe")
{
if (!p.HasExited)
{
p.Kill();
p.WaitForExit();
}
}
});
}
}
catch (System.ComponentModel.Win32Exception ex)

View File

@ -41,7 +41,7 @@ namespace Symphony
}
// Detect if Symphony is running
bool isRunning = System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1;
bool isRunning = (System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1 || System.Diagnostics.Process.GetProcessesByName("C9Shell").Length >= 1);
if (!isRunning)
{
// If it is not running, change the label of the "Next" button to "Install" as the CloseDialog will be skipped
@ -81,7 +81,7 @@ namespace Symphony
}
// Detect if Symphony is running
bool isRunning = System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1;
bool isRunning = (System.Diagnostics.Process.GetProcessesByName("Symphony").Length > 1 || System.Diagnostics.Process.GetProcessesByName("C9Shell").Length >= 1);
if (isRunning)
{
// If it is running, continue to the "Close Symphony" screen

17
package-lock.json generated
View File

@ -70,6 +70,7 @@
"typescript": "3.9.7"
},
"optionalDependencies": {
"@symphony/symphony-c9-shell": "3.14.99-37",
"auto-update": "file:auto_update",
"screen-share-indicator-frame": "git+https://github.com/symphonyoss/ScreenShareIndicatorFrame.git#v1.4.13",
"screen-snippet": "git+https://github.com/symphonyoss/ScreenSnippet2.git#9.2.2",
@ -2288,6 +2289,16 @@
"dev": true,
"license": "(Unlicense OR Apache-2.0)"
},
"node_modules/@symphony/symphony-c9-shell": {
"version": "3.14.99-37",
"resolved": "https://repo.symphony.com/artifactory/api/npm/npm-virtual-dev/@symphony/symphony-c9-shell/-/@symphony/symphony-c9-shell-3.14.99-37.tgz",
"integrity": "sha1-Xl2kS4dN4jlSd6oBdzBLfUd/Mnc=",
"license": "UNLICENSED",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://repo.symphony.com/artifactory/api/npm/npm-virtual-dev/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
@ -26817,6 +26828,12 @@
"integrity": "sha1-jaXGUwkVZT86Hzj9XxAdjD+AecU=",
"dev": true
},
"@symphony/symphony-c9-shell": {
"version": "3.14.99-37",
"resolved": "https://repo.symphony.com/artifactory/api/npm/npm-virtual-dev/@symphony/symphony-c9-shell/-/@symphony/symphony-c9-shell-3.14.99-37.tgz",
"integrity": "sha1-Xl2kS4dN4jlSd6oBdzBLfUd/Mnc=",
"optional": true
},
"@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://repo.symphony.com/artifactory/api/npm/npm-virtual-dev/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",

View File

@ -170,6 +170,7 @@
"shell-path": "^3.0.0"
},
"optionalDependencies": {
"@symphony/symphony-c9-shell": "3.14.99-37",
"auto-update": "file:auto_update",
"screen-share-indicator-frame": "git+https://github.com/symphonyoss/ScreenShareIndicatorFrame.git#v1.4.13",
"screen-snippet": "git+https://github.com/symphonyoss/ScreenSnippet2.git#9.2.2",

View File

@ -91,6 +91,10 @@ set installerDir="%CD%\installer\win"
set distDir="%CD%\dist"
set rootDir="%CD%"
echo "Move optional symphony-c9-shell files into place: " "%distDir%\win-unpacked\cloud9"
mkdir "%distDir%\win-unpacked\cloud9"
move /y "%rootDir%\node_modules\@symphony\symphony-c9-shell\shell" "%distDir%\win-unpacked\cloud9"
if NOT EXIST "%PFX_DIR%\%PFX_FILE%" (
echo "can not find .pfx file" "%pfxDir%\%pfxFile%"
exit /b -1

View File

@ -0,0 +1,44 @@
import { connectC9Pipe } from '../src/app/c9-pipe-handler';
import { createConnection } from 'net';
jest.mock('net');
describe('C9 pipe handler', () => {
const webContentsMocked = { send: jest.fn() };
const mockConnectionEvents = new Map<String, any>();
const mockCreateConnection = (createConnection as unknown) as jest.MockInstance<
typeof createConnection
>;
beforeEach(() => {
jest.clearAllMocks().resetModules();
mockCreateConnection.mockImplementation((_path, onConnect: () => void) => {
onConnect();
return {
on: (event, callback) => {
mockConnectionEvents.set(event, callback);
},
destroy: jest.fn(),
};
});
});
describe('connect', () => {
it('success', () => {
connectC9Pipe(webContentsMocked as any, 'symphony-c9-test');
expect(webContentsMocked.send).toHaveBeenCalledWith(
'c9-pipe-event',
expect.objectContaining({ event: 'connected' }),
);
});
it('data', () => {
connectC9Pipe(webContentsMocked as any, 'symphony-c9-test');
mockConnectionEvents.get('data')('the data');
expect(webContentsMocked.send).toHaveBeenCalledWith(
'c9-pipe-event',
expect.objectContaining({ event: 'data', arg: 'the data' }),
);
});
});
});

View File

@ -7,6 +7,7 @@ import * as windowActions from '../src/app/window-actions';
import { windowHandler } from '../src/app/window-handler';
import * as utils from '../src/app/window-utils';
import { apiCmds, apiName } from '../src/common/api-interface';
import * as c9PipeHandler from '../src/app/c9-pipe-handler';
import { logger } from '../src/common/logger';
import { BrowserWindow, ipcMain } from './__mocks__/electron';
@ -503,5 +504,16 @@ describe('main api handler', () => {
expect(windows['popout1'].getNativeWindowHandle).toBeCalledTimes(1);
expect(windows['popout2'].getNativeWindowHandle).toBeCalledTimes(0);
});
it('should call `connectC9Pipe` correctly', () => {
const spy = jest.spyOn(c9PipeHandler, 'connectC9Pipe');
const value = {
cmd: apiCmds.connectCloud9Pipe,
pipe: 'pipe-name',
};
const expectedValue = [{ send: expect.any(Function) }, 'pipe-name'];
ipcMain.send(apiName.symphonyApi, value);
expect(spy).toBeCalledWith(...expectedValue);
});
});
});

115
src/app/c9-pipe-handler.ts Normal file
View File

@ -0,0 +1,115 @@
import { WebContents } from 'electron';
import { createConnection, Socket } from 'net';
import { logger } from '../common/logger';
class C9PipeHandler {
private _socket: Socket | undefined;
/**
* Connects to the C9 pipe server. Errors will be reported as events.
* @param sender Where to send incoming events
* @param pipe pipe identifier
*/
public connect(sender: WebContents, pipe: string) {
let connectionSuccess = false;
if (!pipe.startsWith('symphony-c9-')) {
logger.info('c9-pipe: Invalid pipe name specified: ' + pipe);
sender.send('c9-pipe-event', {
event: 'connection-failed',
arg: 'invalid pipe',
});
return;
}
const path = '\\\\?\\pipe\\' + pipe;
logger.info('c9-pipe: Connecting to ' + path);
const client = createConnection(path, () => {
connectionSuccess = true;
logger.info('c9-pipe: Connected to ' + path);
sender.send('c9-pipe-event', { event: 'connected' });
});
this._socket = client;
client.on('data', (data) => {
sender.send('c9-pipe-event', { event: 'data', arg: data });
});
client.on('close', () => {
// If the socket is successfully connected, the close is coming from the server side, or
// is otherwise unexpected. In this case, send close event to the extension so it can go
// into a reconnect loop.
if (connectionSuccess) {
logger.info('c9-pipe: Server closed ' + path);
sender.send('c9-pipe-event', { event: 'close' });
this._socket?.destroy();
}
});
client.on('error', (err: Error) => {
// If the connection is already established, any error will also result in a 'close' event
// and will be handled above.
logger.info('c9-pipe: Error from ' + path, err);
if (!connectionSuccess) {
sender.send('c9-pipe-event', {
event: 'connection-failed',
arg: err.message,
});
}
});
}
/**
* Writes data to the pipe
* @param data the data to be written
*/
public write(data: Uint8Array) {
this._socket?.write(data);
}
/**
* Closes an open pipe
*/
public close() {
logger.info('c9-pipe: Closing pipe');
this._socket?.destroy();
this._socket = undefined;
}
/**
* Returns whether the pipe is open
*/
public isConnected() {
return this._socket !== undefined;
}
}
let c9PipeHandler: C9PipeHandler | undefined;
/**
* Connects to the C9 pipe server. Errors will be reported as callbacks.
* @param sender Where to send incoming events
* @param pipe pipe identifier
*/
export const connectC9Pipe = (sender: WebContents, pipe: string) => {
if (!c9PipeHandler) {
c9PipeHandler = new C9PipeHandler();
} else {
if (c9PipeHandler.isConnected()) {
c9PipeHandler.close();
}
}
c9PipeHandler.connect(sender, pipe);
};
/**
* Writes data to the pipe
* @param data the data to be written
*/
export const writeC9Pipe = (data: Uint8Array) => {
c9PipeHandler?.write(data);
};
/**
* Closes an open pipe
*/
export const closeC9Pipe = () => {
c9PipeHandler?.close();
};

161
src/app/c9-shell-handler.ts Normal file
View File

@ -0,0 +1,161 @@
import { app, WebContents } from 'electron';
import { isDevEnv, isWindowsOS } from '../common/env';
import { logger } from '../common/logger';
import { ChildProcess, spawn } from 'child_process';
import * as path from 'path';
import { getCommandLineArgs, getGuid } from '../common/utils';
/**
* Current state of the C9 shell process.
*/
export interface IShellStatus {
status: 'inactive' | 'starting' | 'active';
pipeName?: string;
}
type StatusCallback = (status: IShellStatus) => void;
class C9ShellHandler {
private _c9shell: ChildProcess | undefined;
private _curStatus: IShellStatus | undefined;
private _statusCallback: StatusCallback | undefined;
/**
* Starts the c9shell process
*/
public startShell() {
if (this._attachExistingC9Shell()) {
return;
}
if (!this._c9shell) {
this._c9shell = this._launchC9Shell();
}
}
/**
* Allows the C9 extension to subscribe to shell status updates. Immediately sends current status.
*/
public setStatusCallback(callback: StatusCallback) {
this._statusCallback = callback;
if (!this._statusCallback) {
return;
}
if (this._curStatus) {
this._statusCallback(this._curStatus);
}
}
/**
* Update the current shell status and notify the callback if set.
*/
private _updateStatus(status: IShellStatus) {
this._curStatus = status;
if (this._statusCallback) {
this._statusCallback(status);
}
}
/**
* Checks if the user wants to control the C9 shell process explicitly, returns true if so
* @returns
*/
private _attachExistingC9Shell(): boolean {
const customC9ShellPipe = getCommandLineArgs(
process.argv,
'--c9pipe=',
false,
);
if (customC9ShellPipe) {
logger.info(`c9-shell: Using custom pipe: ${customC9ShellPipe}`);
this._updateStatus({
status: 'active',
pipeName: 'symphony-c9-' + customC9ShellPipe.substring(9),
});
return true;
} else {
return false;
}
}
/**
* Launches the correct c9shell process
*/
private _launchC9Shell(): ChildProcess | undefined {
this._curStatus = undefined;
const uniquePipeName = getGuid();
const c9ShellPath = isDevEnv
? path.join(
__dirname,
'../../../node_modules/@symphony/symphony-c9-shell/shell/c9shell.exe',
)
: path.join(path.dirname(app.getPath('exe')), 'cloud9/shell/c9shell.exe');
const customC9ShellArgs = getCommandLineArgs(
process.argv,
'--c9args=',
false,
);
const customC9ShellArgList = customC9ShellArgs
? customC9ShellArgs.substring(9).split(' ')
: [];
logger.info('c9-shell: launching shell', c9ShellPath, customC9ShellArgList);
this._updateStatus({ status: 'starting' });
const c9Shell = spawn(
c9ShellPath,
[
'--allowmultiproc',
'--symphonyHost',
uniquePipeName,
...customC9ShellArgList,
],
{
stdio: 'pipe',
},
);
c9Shell.on('close', (code) => {
logger.info('c9-shell: closed with code', code);
this._c9shell = undefined;
this._updateStatus({ status: 'inactive' });
});
c9Shell.on('spawn', () => {
logger.info('c9-shell: shell process successfully spawned');
this._updateStatus({
status: 'active',
pipeName: 'symphony-c9-' + uniquePipeName,
});
});
c9Shell.stdout.on('data', (data) => {
logger.info(`c9-shell: ${data.toString().trim()}`);
});
c9Shell.stderr.on('data', (data) => {
logger.error(`c9-shell: ${data.toString().trim()}`);
});
return c9Shell;
}
}
let c9ShellHandler: C9ShellHandler | undefined;
/**
* Starts the C9 shell process asynchronously, if not already started.
*/
export const loadC9Shell = (sender: WebContents) => {
if (!isWindowsOS) {
logger.error("c9-shell: can't load shell on non-Windows OS");
return;
}
if (!c9ShellHandler) {
c9ShellHandler = new C9ShellHandler();
}
c9ShellHandler.setStatusCallback((status: IShellStatus) => {
logger.info('c9-shell: sending status', status);
sender.send('c9-status-event', { status });
});
c9ShellHandler.startShell();
};

View File

@ -18,6 +18,8 @@ import { logger } from '../common/logger';
import { activityDetection } from './activity-detection';
import { analytics } from './analytics-handler';
import appStateHandler from './app-state-handler';
import { closeC9Pipe, connectC9Pipe, writeC9Pipe } from './c9-pipe-handler';
import { loadC9Shell } from './c9-shell-handler';
import { getCitrixMediaRedirectionStatus } from './citrix-handler';
import { CloudConfigDataTypes, config, ICloudConfig } from './config-handler';
import { downloadHandler } from './download-handler';
@ -356,6 +358,18 @@ ipcMain.on(
swiftSearchInstance.handleMessageEvents(arg.swiftSearchData);
}
break;
case apiCmds.connectCloud9Pipe:
connectC9Pipe(event.sender, arg.pipe);
break;
case apiCmds.writeCloud9Pipe:
writeC9Pipe(arg.data);
break;
case apiCmds.closeCloud9Pipe:
closeC9Pipe();
break;
case apiCmds.launchCloud9:
loadC9Shell(event.sender);
break;
default:
break;
}

View File

@ -31,6 +31,7 @@ import {
import { notification } from '../renderer/notification';
import { cleanAppCacheOnCrash } from './app-cache-handler';
import { AppMenu } from './app-menu';
import { closeC9Pipe } from './c9-pipe-handler';
import { handleChildWindow } from './child-window-handler';
import {
CloudConfigDataTypes,
@ -468,6 +469,8 @@ export class WindowHandler {
// reset to false when the client reloads
this.isMana = false;
logger.info(`window-handler: main window web contents finished loading!`);
// Make sure there is no lingering C9 pipe connection
closeC9Pipe();
// early exit if the window has already been destroyed
if (!this.mainWebContents || this.mainWebContents.isDestroyed()) {
logger.info(

View File

@ -65,6 +65,10 @@ export enum apiCmds {
getNativeWindowHandle = 'get-native-window-handle',
getCitrixMediaRedirectionStatus = 'get-citrix-media-redirection-status',
getSources = 'getSources',
launchCloud9 = 'launch-cloud9',
connectCloud9Pipe = 'connect-cloud9-pipe',
writeCloud9Pipe = 'write-cloud9-pipe',
closeCloud9Pipe = 'close-cloud9-pipe',
}
export enum apiName {
@ -115,6 +119,8 @@ export interface IApiArgs {
swiftSearchData: any;
types: string[];
thumbnailSize: Size;
pipe: string;
data: Uint8Array;
}
export type Themes = 'light' | 'dark';
@ -269,3 +275,8 @@ export type NotificationActionCallback = (
) => void;
export type ConfigUpdateType = 'restart' | 'reload';
export interface ICloud9Pipe {
write(data: Uint8Array): void;
close(): void;
}

View File

@ -92,6 +92,8 @@ if (ssfWindow.ssf) {
getCitrixMediaRedirectionStatus:
ssfWindow.ssf.getCitrixMediaRedirectionStatus,
registerClientBanner: ssfWindow.ssf.registerClientBanner,
launchCloud9: ssfWindow.ssf.launchCloud9,
connectCloud9Pipe: ssfWindow.ssf.connectCloud9Pipe,
});
}

View File

@ -5,6 +5,7 @@ import {
searchAPIVersion,
version,
} from '../../package.json';
import { IShellStatus } from '../app/c9-shell-handler';
import { RedirectionStatus } from '../app/citrix-handler';
import { IDownloadItem } from '../app/download-handler';
import {
@ -13,6 +14,7 @@ import {
ConfigUpdateType,
IBadgeCount,
IBoundsChange,
ICloud9Pipe,
ICPUUsage,
ILogMsg,
IMediaPermission,
@ -69,6 +71,8 @@ export interface ILocalObject {
showClientBannerCallback?: Array<
(reason: string, action: ConfigUpdateType) => void
>;
c9PipeEventCallback?: (event: string, arg?: any) => void;
c9MessageCallback?: (status: IShellStatus) => void;
}
const local: ILocalObject = {
@ -776,6 +780,80 @@ export class SSFApi {
local.showClientBannerCallback.push(callback);
}
}
/**
* Connects to a Cloud9 pipe
*
* @param pipe pipe name
* @param onData callback that is invoked when data is received over the connection
* @param onClose callback that is invoked when the connection is closed by the remote side
* @returns Cloud9 pipe instance promise
*/
public connectCloud9Pipe(
pipe: string,
onData: (data: Uint8Array) => void,
onClose: () => void,
): Promise<ICloud9Pipe> {
if (
typeof pipe === 'string' &&
typeof onData === 'function' &&
typeof onClose === 'function'
) {
if (local.c9PipeEventCallback) {
return Promise.reject("Can't connect to pipe, already connected");
}
return new Promise<ICloud9Pipe>((resolve, reject) => {
local.c9PipeEventCallback = (event: string, arg?: any) => {
switch (event) {
case 'connected':
const ret = {
write: (data: Uint8Array) => {
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.writeCloud9Pipe,
data,
});
},
close: () => {
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.closeCloud9Pipe,
});
},
};
resolve(ret);
break;
case 'connection-failed':
local.c9PipeEventCallback = undefined;
reject(arg);
break;
case 'data':
onData(arg);
break;
case 'close':
local.c9PipeEventCallback = undefined;
onClose();
break;
}
};
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.connectCloud9Pipe,
pipe,
});
});
} else {
return Promise.reject('Invalid arguments');
}
}
/**
* Launches the Cloud9 client.
*/
public launchCloud9(callback: (status: IShellStatus) => void): void {
local.c9MessageCallback = callback;
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.launchCloud9,
});
}
}
/**
@ -1018,6 +1096,20 @@ local.ipcRenderer.on('display-client-banner', (_event, args) => {
}
});
/**
* An event triggered by the main process when a cloud9 pipe event occurs
*/
local.ipcRenderer.on('c9-pipe-event', (_event, args) => {
local.c9PipeEventCallback?.call(null, args.event, args?.arg);
});
/**
* An event triggered by the main process when the status of the cloud9 client changes
*/
local.ipcRenderer.on('c9-status-event', (_event, args) => {
local.c9MessageCallback?.call(null, args?.status);
});
// Invoked whenever the app is reloaded/navigated
const sanitize = (): void => {
if (window.name === apiName.mainWindowName) {