Woongjae Lee
NHN Dooray - Frontend Team
Mark Lee, Studio XID
2woongjae @ gmail.com
Marks:pkg-example-basic mark$ pkg index.js
> pkg@4.1.3
> Targets not specified. Assuming:
node8-linux-x64, node8-macos-x64, node8-win-x64
> Fetching base Node.js binaries to: ~/.pkg-cache
fetched-v8.0.0-linux-x64 [====================] 100%
fetched-v8.0.0-macos-x64 [====================] 100%
fetched-v8.0.0-win-x64 [====================] 100%
Marks-MacBook-Pro:pkg-example-basic mark$ ./index-macos
0
1
2
3
/*
index.js 를 메인 엔트리로 해서,
자동으로 3개의 OS 에 맞춰서 실행파일이 만들어짐.
index-linux
index-macos
index-win.exe
*/
Marks:pkg-example-basic mark$ pkg .
> pkg@4.1.3
> Targets not specified. Assuming:
node8-linux-x64, node8-macos-x64, node8-win-x64
Marks:pkg-example-basic mark$ ./pkg-example-basic-macos
0
1
2
3
/*
package.json 의 bin 프로퍼티에 설정된 파일을 메인 엔트리로 해서,
자동으로 3개의 OS 에 맞춰서 실행파일이 만들어짐.
실행파일 이름은 package.json 의 name 프로퍼티+OS
pkg-example-basic-linux
pkg-example-basic-macos
pkg-example-basic-win.exe
*/
Renderer
main
binary
node-ipc
ipcMain
ipcRenderer
TunnelBridge
TunnelClient
TunnelServer
app.on('ready', async function () {
/*
* 0 단계
* 서버를 띄울때 미리 구할 필요가 있는 값들을 구하기
*/
const display = screen.getPrimaryDisplay();
/*
* 1 단계
* pkg 로 패키징 된 바이너리 실행파일을 실행
* 만약에 서버를 띄우면서 넘겨줄 내용이 있다면, 먼저 구해서 인자로 담아서 실행
* */
server = await serverLoader.load(display);
/*
* 2 단계
* electron 의 ipcMain 과 서버
* electron 의 ipcRenderer 와 서버
* 를 연결
*/
const bridge = new TunnelBridge(ipcMain);
await bridge.connectToServer();
/*
* 3 단계
* electron 의 메인 라이브러리를 사용하기 위한 연결 객체 생성
*/
const puppetMaster = new PuppetMaster(bridge.serverChannel);
/*
* 서버가 종료되면, 앱 종료
*/
serverLoader.on('close', () => {
puppetMaster.destroy();
app.quit();
});
});
// 개발 모드
private _loadInFeDevMode(spawnArgs) {
const server = cp.fork(path.join(__dirname, '../browser/main.js'), [JSON.stringify(spawnArgs)], getSpawningOption());
server.on('error', err => console.log(`Failed to start child process. ${JSON.stringify(err)}`));
server.on('close', code => {
console.log(`Server process exited with code ${code}`);
this._events.emit('close');
});
return server;
}
// 패키징 모드
private _loadInReleaseMode(cliArgs) {
const spawningOption = getSpawningOption();
spawningOption.stdio = ['ignore', 'ignore', 'ignore'];
const server = cp.spawn(path.join(__dirname, '../../../bin', '.server'), [JSON.stringify(cliArgs)], spawningOption);
server.on('error', err => console.log(`Failed to start child process. ${JSON.stringify(err)}`));
server.on('close', code => {
console.log(`Server process exited with code ${code}`);
this._events.emit('close');
});
return server;
}
constructor(ipcMain: Electron.IpcMain) {
this._client = ipcMain;
// 랜더러 프로세스가 생성되면, ipcMain 으로 contextId 를 담아 'establish_tunnel' 이벤트를 보낸다.
this._client.on('establish_tunnel', (event, contextId) => {
// 랜더러 프로세스로 보낼수 있는 sender(들)을, 보관한다.
const sender = event.sender;
this._renderers[contextId] = sender;
if (this._server) {
// 서버가 있으면, 랜더러 프로세스로 통로가 생겼음을 서버에게 알린다.
sender.send('tunnel_established', 'HELLO CLIENT');
}
});
this._client.on('tunnel_disconnect', (event, contextId) => {
// 랜더러 프로세스가 사라지면, 'tunnel_disconnect' 이벤트를 보내서 sender 를 리스트에서 삭제한다.
if (contextId in this._renderers) {
delete this._renderers[contextId];
}
});
}
// node-ipc 설정
ipc.config.id = 'client';
ipc.config.appspace = `app_${process.getuid()}.`;
ipc.config.socketRoot = '/tmp/';
ipc.config.retry = 100;
ipc.config.silent = true;
...
public async connectToServer() {
// node-ipc 서버에 클라이언트를 연결하는 부분
return new Promise((resolve, reject) => {
try {
ipc.connectTo('server', () => {
ipc.of.server.on('connect', () => {
// node-ipc 서버에 정상적으로 연결되었다.
// 1. node-ipc 서버와 랜더러 프로세스의 sender 들을 연결하는 부분
this._onConnect(ipc.of.server);
// 2. node-ipc 서버와 일렉트론 메인 프로세스의 메인 채널을 연결하는 부분
this.serverChannel.connect(ipc.of.server);
resolve(ipc.of.server);
});
});
} catch (err) {
reject(err);
}
});
}
private _onConnect(server) {
// node-ipc 서버
this._server = server;
// node-ipc 서버에세 메세지가 오면, 저장된 랜더러 프로세스의 sender 에서 찾아서 보낸다.
this._server.on('server_to_client', (data) => {
let renderers;
if (data.contextId) {
renderers = [this._renderers[data.contextId]];
} else {
renderers = [];
for (let key in this._renderers) {
renderers.push(this._renderers[key]);
}
}
for (let renderer of renderers) {
if (renderer) {
renderer.send('server_to_client', data.channel, ...data.payload);
}
}
});
// 랜더러 프로세스에서 ipcMain 으로 메세지가 들어오면, node-ipc 서버로 보낸다.
this._client.on('client_to_server', (event, contextId, channel, ...payload) => {
this._server.emit('client_to_server', {channel, contextId, payload});
});
this.serverChannel.on('context-destroyed', contextId => {
delete this._renderers[contextId];
});
}
class MainToServerChannel {
private _events = new EventEmitter();
private _server = null;
public connect(server) {
// node-ipc 서버의 정보를 저장한다.
this._server = server;
// node-ipc 서버에서 메세지를 받아, 일렉트론 메인 프로세스로 보낸다.
this._server.on('server_to_main', data => {
this._events.emit(data.channel, ...data.payload);
});
// node-ipc 서버에게 일렉트론 메인 프로세스와의 연결을 통보한다.
this._server.emit('register_main', {});
}
// 이벤트를 바인딩 하도록 처리
public on(channel: string, listener: (...args: any[]) => void) {
this._events.on(channel, listener);
}
// 일렉트론 메인 프로세에서 node-ipc 서버로 메세지를 보낸다.
public send(channel, ...payload) {
this._server.emit('main_to_server', {channel, payload});
}
}
private _constructorMap = {
'BrowserWindow': BrowserWindowPuppet,
'Dialog': DialogPuppet
};
...
constructor(server) {
// node-ipc 와 연결하는 메인 채널을 저장한다.
this._server = server;
// puppet.new 를 통해 새로 하위 채널들을 확보한다.
server.on('puppet.new', (className, objectId, ...args) => {
const constructor = this._constructorMap[className];
if (!constructor) {
throw new Error(`Unknown puppet class ${className} requested`);
}
const objectChannel = new PuppetObjectChannel(server, objectId);
const obj = new constructor(objectChannel, ...args);
obj[PuppetMaster.OBJECT_ID_SYMBOL] = objectId;
this._objects[objectId] = obj;
});
}
import { dialog } from 'electron';
export class DialogPuppet {
private _server = null;
constructor(server) {
console.log('DialogPuppet.constructor');
// 하위 채널
this._server = server;
this._server.on('Dialog.showMessageBox', options => {
dialog.showMessageBox(options, index => {
this._server.send('callback:Dialog.showMessageBox', index);
});
});
}
}
(async function () {
let contextManager: ContextManager = null;
try {
contextManager = new ContextManager();
const ignored = contextManager.start();
} catch (e) {
console.log(e);
}
}());
constructor() {
// 서버 프로세스가 args 와 함께 시작
const spawnOption: ServerSpawnArgs = JSON.parse(process.argv.filter(str => str.startsWith('{'))[0] || '{}');
this._spawnOption = spawnOption;
// node-ipc 서버 설정을 하고, node-ipc 클라이언트와 연결하여, 이벤트를 연결할 매개체를 만든다.
this.ipc = new TunnelServer();
this.ipc.start();
// 다이얼로그 프록시를 만들어 다이얼로그 퍼펫과 통신하도록 설정
this.dialog = new DialogProxy(this.ipc);
}
// node-ipc 서버 설정
ipc.config.id = 'server';
ipc.config.retry = 3000;
ipc.config.silent = true;
...
public start() {
// 터널 서버를 설정한다.
ipc.serve(`/tmp/app_${process.getuid()}.server`, (data, socket) => this._onServerStart(ipc.server));
ipc.server.define.listen['client_to_server'] = 'This event type listens for message strings as value of data key.';
ipc.server.start();
}
private _onServerStart(server: IpcServer) {
this._server = server;
// server 의 메인 채널과 node-ipc 서버를 연결
this.mainChannel.connect(server);
// 랜더러 프로세스에서 보낸 메세지를 node-ipc 서버를 통해 받도록 처리
this._server.on('client_to_server', (data, socket) => {
let sender;
let contextId = data.contextId;
if (contextId && contextId in this._senderMap) {
sender = this._senderMap[contextId];
} else {
sender = new Sender(this._server, socket, contextId);
this._senderMap[contextId] = sender;
}
const event = new Event(sender);
this._events.emit(contextId, data.channel, event, ...data.payload);
});
}
// 만들어진 node-ipc 서버와, 연결할 채널을 생성
// electrin main 의 메인 채널과의 통신
public connect(server: IpcServer): void {
this._server = server;
this._server.on('register_main', (data, socket) => {
this._mainSocket = socket;
this._flushSendToMain();
});
this._server.on('main_to_server', (data) => {
this._events.emit(data.channel, ...data.payload);
});
}
import { EventEmitter } from 'events';
import { ServerToMainChannel, TunnelServer } from './TunnelServer';
export class DelegatedChannel {
public static counter = 0;
private _events = new EventEmitter();
private _server: TunnelServer | ServerToMainChannel = null;
private _channelId = null;
private _isDestroyed = false;
constructor(server: TunnelServer | ServerToMainChannel, channelId?: string) {
this._server = server;
this._channelId = channelId || 'DelegatedChannel-' + DelegatedChannel.counter++;
server.on(this._channelId, (channel, ...args) => {
this._events.emit(channel, ...args);
});
}
public getChannelId() {
this._checkIsDestroyed();
return this._channelId;
}
public on(channel: string, listener: (...args: any[]) => void): void {
this._checkIsDestroyed();
this._events.on(channel, listener);
}
public removeAllListeners(subchannel): void {
this._checkIsDestroyed();
this._events.removeAllListeners(subchannel);
}
public send(subchannel, ...args): void {
this._checkIsDestroyed();
if (this._server instanceof TunnelServer) {
throw new Error('Cannot send request to main because ipc is TunnelServer');
}
this._server.send(this._channelId, subchannel, ...args);
}
public sendAtRootLevel(channel, ...args): void {
this._checkIsDestroyed();
if (this._server instanceof TunnelServer) {
throw new Error('Cannot send request to main because ipc is TunnelServer');
}
this._server.sendAtRootLevel(channel, ...args);
}
public async destroy(): Promise<void> {
this._isDestroyed = true;
this._events.removeAllListeners(this._channelId);
}
public isDestroyed(): boolean {
return this._isDestroyed;
}
private _checkIsDestroyed(): void {
if (this._isDestroyed) {
throw new Error(`${this.constructor.name} is already destroyed`);
}
}
}
public async init(): Promise<void> {
// contextId 를 만들고,
this.contextId = createContextId();
// DelegatedChannel 을 먼저 만든 후, 각종 필요한 이벤트를 바인딩 시키고
this.channel = new DelegatedChannel(this._contextManager.ipc, this.contextId);
this.channel.on('client_initialized', () => {
this._mainWindow.show();
this._mainWindow.focus();
this._isClientInitialized = true;
});
this.channel.on('test', () => {
this._mainWindow.openDevTools();
});
// 실제 랜더러 프로세스를 만든다.
this._mainWindow = this._createBrowserWindow();
}
$(async function () {
try {
console.log('Renderer start');
const tunnel = new TunnelClient(ipcRenderer, contextId);
const result = await tunnel.connect();
console.log(`connect result : ${result}`);
// 랜더러가 종료될때 터널에 끝낸다고 보낸다.
window.addEventListener('unload', () => tunnel.disconnect());
$('#sendServer').click(() => {
console.log('서버로 액션을 보냅니다.');
tunnel.send('test');
});
// 터널에 랜더러가 잘 생성되었을을 알린다.
tunnel.send('client_initialized');
} catch (e) {
console.error(e);
}
});
import { EventEmitter } from 'events';
export class TunnelClient {
private _ipc;
private _contextId: string;
private _events = new EventEmitter();
constructor(ipc, contextId: string) {
this._ipc = ipc;
this._contextId = contextId;
this._ipc.on('server_to_client', (event, channel, ...args) => {
this._events.emit(channel, ...args);
});
}
public async connect(): Promise<string> {
return new Promise<string>(resolve => {
this._ipc.once('tunnel_established', () => {
resolve('OK');
});
this._ipc.send('establish_tunnel', this._contextId);
window.addEventListener('unload', () => this.disconnect());
});
}
public on(channel, callback): void {
this._events.on(channel, callback);
}
public send(channel, ...args): void {
this._ipc.send('client_to_server', this._contextId, channel, ...args);
}
public disconnect(): void {
this._ipc.sendSync('tunnel_disconnect', this._contextId);
}
}
{
"name": "protecting-javascript-source-code-in-electron",
"productName": "Meetup-0818",
"companyName": "Seoul.js.org",
"version": "1.0.0",
"author": "Mark Lee <2woongjae@gmail.com>",
"description": "Protecting JavaScript Source Code in Electron",
"license": "UNLICENSED",
"main": "./output/electron/main.js",
"scripts": {
"clean": "node ./script/clean.js",
"pack": "node ./script/pack.js",
"transpile": "gulp build",
"pkg": "pkg -t node7-macos-x64 --output bin/.server output/server.js",
"start": "electron ."
},
"build": {
"appId": "org.js.seoul",
"copyright": "Copyright(C) 2017 Seoul.js.org",
"productName": "Meetup-0818",
"electronVersion": "1.7.5",
"asar": false,
"extraResources": [
"bin"
],
"publish": [
{
"provider": "s3",
"bucket": "seoul.js.org"
}
],
"mac": {
"target": [
"dir"
]
}
},
"devDependencies": {
"@types/jquery": "^3.2.11",
"@types/source-map-support": "^0.4.0",
"@types/uuid": "^3.4.0",
"electron": "^1.7.5",
"electron-builder": "^19.22.1",
"gulp": "^3.9.1",
"gulp-clean": "^0.3.2",
"gulp-sourcemaps": "^2.6.0",
"gulp-tslint": "^8.1.2",
"gulp-typescript": "^3.2.1",
"pkg": "^4.2.3",
"rimraf": "^2.6.1",
"tslint": "^5.6.0",
"typescript": "^2.4.2"
},
"dependencies": {
"fs-extra-promise": "^1.0.1",
"jquery": "^3.2.1",
"node-ipc": "git+https://github.com/enclosable/node-ipc.git",
"source-map-support": "^0.4.15",
"uuid": "^3.1.0"
},
"runtimeDependencies": [
"jquery",
"source-map-support",
"node-ipc"
]
}
// 타입스크립트 컴파일
exec("npm run transpile");
// 앱에 들어갈 코드들을 모아둘 app 폴더 생성
mkdir("app");
// 바이너리 파일이 생성될 bin 폴더 생성
mkdir("bin");
// 바이너리 파일 생성
exec("npm run pkg");
// app 폴더 안에 package.json 을 만들어서 넣기
cat_write(create_production_packages_json(), "app/package.json");
// 컴파일 된 js 파일들이 있는 output 폴더를 app 안으로 복사
cp("output", "app/output");
// html, css, image 파일들이 있는 static 폴더를 app 안으로 복사
cp("static", "app/static");
// 바이너리로 이미 만들어진 파일들을 제거
rm_rf("./app/output/browser");
// 맵 파일들 제거
for(let mapFile of find("./app/output", /.*\.js\.map$/)) {
rm_rf(mapFile);
}
// app 폴더로 이동해서 npm 인스톨
cd("app");
exec("npm install");
cd(PROJECT_ROOT);
// 바이너리 파일이 있는 폴더를 app 폴더 안으로 복사
cp("bin", "app/bin");
// electron-builder 로 실행파일 만들기
exec("./node_modules/.bin/build");
// 청소
cleanup();
/* eslint-disable complexity */
import {
STORE_BLOB, STORE_CONTENT, STORE_LINKS,
STORE_STAT, isDotJS, isDotJSON, isDotNODE
} from '../prelude/common.js';
import { log, wasReported } from './log.js';
import assert from 'assert';
import fs from 'fs-extra';
import { version } from '../package.json';
const bootstrapText = fs.readFileSync(
require.resolve('../prelude/bootstrap.js'), 'utf8'
).replace('%VERSION%', version);
const commonText = fs.readFileSync(
require.resolve('../prelude/common.js'), 'utf8'
);
function itemsToText (items) {
const len = items.length;
return len.toString() +
(len % 10 === 1 ? ' item' : ' items');
}
function hasAnyStore (record) {
// discarded records like native addons
for (const store of [ STORE_BLOB, STORE_CONTENT, STORE_LINKS, STORE_STAT ]) {
if (record[store]) return true;
}
return false;
}
export default function ({ records, entrypoint, bytecode }) {
const stripes = [];
for (const snap in records) {
const record = records[snap];
const { file } = record;
if (!hasAnyStore(record)) continue;
assert(record[STORE_STAT], 'packer: no STORE_STAT');
if (isDotNODE(file)) {
continue;
} else {
assert(record[STORE_BLOB] || record[STORE_CONTENT] || record[STORE_LINKS]);
}
if (record[STORE_BLOB] && !bytecode) {
delete record[STORE_BLOB];
if (!record[STORE_CONTENT]) {
// TODO make a test for it?
throw wasReported('--no-bytecode and no source breaks final executable', [ file,
'Please run with "-d" and without "--no-bytecode" first, and make',
'sure that debug log does not contain "was included as bytecode".' ]);
}
}
for (const store of [ STORE_BLOB, STORE_CONTENT, STORE_LINKS, STORE_STAT ]) {
const value = record[store];
if (!value) continue;
if (store === STORE_BLOB ||
store === STORE_CONTENT) {
if (record.body === undefined) {
stripes.push({ snap, store, file });
} else
if (Buffer.isBuffer(record.body)) {
stripes.push({ snap, store, buffer: record.body });
} else
if (typeof record.body === 'string') {
stripes.push({ snap, store, buffer: Buffer.from(record.body) });
} else {
assert(false, 'packer: bad STORE_BLOB/STORE_CONTENT');
}
} else
if (store === STORE_LINKS) {
if (Array.isArray(value)) {
const buffer = Buffer.from(JSON.stringify(value));
stripes.push({ snap, store, buffer });
} else {
assert(false, 'packer: bad STORE_LINKS');
}
} else
if (store === STORE_STAT) {
if (typeof value === 'object') {
const newStat = Object.assign({}, value);
newStat.atime = value.atime.getTime();
newStat.mtime = value.mtime.getTime();
newStat.ctime = value.ctime.getTime();
newStat.birthtime = value.birthtime.getTime();
newStat.isFileValue = value.isFile();
newStat.isDirectoryValue = value.isDirectory();
const buffer = Buffer.from(JSON.stringify(newStat));
stripes.push({ snap, store, buffer });
} else {
assert(false, 'packer: bad STORE_STAT');
}
} else {
assert(false, 'packer: unknown store');
}
}
if (record[STORE_CONTENT]) {
const disclosed = isDotJS(file) || isDotJSON(file);
log.debug(disclosed ? 'The file was included as DISCLOSED code (with sources)'
: 'The file was included as asset content', file);
} else
if (record[STORE_BLOB]) {
log.debug('The file was included as bytecode (no sources)', file);
} else
if (record[STORE_LINKS]) {
const value = record[STORE_LINKS];
log.debug('The directory files list was included (' + itemsToText(value) + ')', file);
}
}
const prelude =
'return (function (REQUIRE_COMMON, VIRTUAL_FILESYSTEM, DEFAULT_ENTRYPOINT) { ' +
bootstrapText +
'\n})(function (exports) {\n' +
commonText +
'\n},\n' +
'%VIRTUAL_FILESYSTEM%' +
'\n,\n' +
'%DEFAULT_ENTRYPOINT%' +
'\n);';
return { prelude, entrypoint, stripes };
}
import { log } from './log.js';
import { spawn } from 'child_process';
const script = `
var vm = require('vm');
var module = require('module');
var stdin = new Buffer(0);
process.stdin.on('data', function (data) {
stdin = Buffer.concat([ stdin, data ]);
if (stdin.length >= 4) {
var sizeOfSnap = stdin.readInt32LE(0);
if (stdin.length >= 4 + sizeOfSnap + 4) {
var sizeOfBody = stdin.readInt32LE(4 + sizeOfSnap);
if (stdin.length >= 4 + sizeOfSnap + 4 + sizeOfBody) {
var snap = stdin.toString('utf8', 4, 4 + sizeOfSnap);
var body = new Buffer(sizeOfBody);
var startOfBody = 4 + sizeOfSnap + 4;
stdin.copy(body, 0, startOfBody, startOfBody + sizeOfBody);
stdin = new Buffer(0);
var code = module.wrap(body);
var s = new vm.Script(code, {
filename: snap,
produceCachedData: true,
sourceless: true
});
if (!s.cachedDataProduced) {
console.error('Pkg: Cached data not produced.');
process.exit(2);
}
var h = new Buffer(4);
var b = s.cachedData;
h.writeInt32LE(b.length, 0);
process.stdout.write(h);
process.stdout.write(b);
}
}
}
});
process.stdin.resume();
`;
const children = {};
export function fabricate (bakes, fabricator, snap, body, cb) {
const cmd = fabricator.binaryPath;
const key = JSON.stringify([ cmd, bakes ]);
let child = children[key];
if (!child) {
const stderr = log.debugMode ? process.stdout : 'ignore';
child = children[key] = spawn(
cmd, [ '--pkg-fallback' ].concat(bakes).concat('-e', script),
{ stdio: [ 'pipe', 'pipe', stderr ] }
);
}
function kill () {
delete children[key];
child.kill();
}
let stdout = Buffer.alloc(0);
function onError (error) {
removeListeners();
kill();
cb(new Error(`Failed to make bytecode for '${JSON.stringify(fabricator)}' (${error.message})`));
}
function onClose (code) {
removeListeners();
kill();
if (code !== 0) {
return cb(new Error(`Failed to make bytecode for '${JSON.stringify(fabricator)}'`));
} else {
return cb(new Error(`${cmd} closed unexpectedly`));
}
}
function onData (data) {
stdout = Buffer.concat([ stdout, data ]);
if (stdout.length >= 4) {
const sizeOfBlob = stdout.readInt32LE(0);
if (stdout.length >= 4 + sizeOfBlob) {
const blob = Buffer.alloc(sizeOfBlob);
stdout.copy(blob, 0, 4, 4 + sizeOfBlob);
removeListeners();
if (fabricator.nodeRange === 'node0') {
// node0 can not produce second time,
// even if first time produced fine,
// probably because of 'filename' cache
kill();
}
return cb(undefined, blob);
}
}
}
child.on('error', onError);
child.on('close', onClose);
child.stdin.on('error', onError);
child.stdout.on('error', onError);
child.stdout.on('data', onData);
function removeListeners () {
child.removeListener('error', onError);
child.removeListener('close', onClose);
child.stdin.removeListener('error', onError);
child.stdout.removeListener('error', onError);
child.stdout.removeListener('data', onData);
}
const h = Buffer.alloc(4);
let b = Buffer.from(snap);
h.writeInt32LE(b.length, 0);
child.stdin.write(h);
child.stdin.write(b);
b = body;
h.writeInt32LE(b.length, 0);
child.stdin.write(h);
child.stdin.write(b);
}
export function shutdown () {
for (const key in children) {
const child = children[key];
child.kill();
}
}
By Woongjae Lee
Seoul.js 미트업 20170818