Protecting JavaScript Source Code    

in Electron    

Mark Lee, Studio XID

2woongjae @ gmail.com

  Electron Framework (19:50)

  • 웹기술로 데스크탑 어플리케이션을 개발할 수 있는 오픈소스 프레임워크
    • 맥북에서 실행하는 앱
    • 윈도우에 설치되는 프로그램
  • 메인 프로세스와 랜더러 프로세스로 나뉜다.
    • 메인은 node.js 라고 생각하자
    • 랜더러는 각각 크롬 브라우저 라고 생각하자
      • html, css, javascript
      • node.js
    • 작업할때 뷰에 관련된 로직은 랜더러 프로세스에서, 비지니스 로직은 메인 프로세스에서 작업하는 것이 일반적
    • 메인 프로세스는 하나, 랜더러 프로세스는 창마다 뜬다.
      • 각각의 실행 환경을 가진다. => ipcMain & ipcRenderer

  ProtoPie

  • 디자이너를 위한 인터렉션 프로토타이핑 도구
  • macOS, Windows 용 소프트웨어 제공
  • Electron + TypeScript 로 제작
  • 동아시아에서 Electron 을 가장 열심히 사용 삽질 한다고 자부함.
  • 소스보호를 위해 enclosejs, pkg 사용
  • 자동업데이트 서버를 직접 구축해서 사용

  발표 흐름 ( special thanks to @dennis )

  • 일렉트론의 소스 보호
  • enclosejs 와 pkg 란 ?
  • 일렉트론에서 소스를 보호하기 위한 노력
    • Step1. 'pkg' 와 'node-ipc' 를 사용한 최소 구현
    • Step2. pkg 를 분석하여, 응용하기
      • vm 모듈 알아보기
      • (직접 만들 혹은 누군가가 만들어줄) 앞으로의 방향
      • 다음 밋업 발표 주제 불확실

  Electron 에서 보안에 관한 개념

  • 필요한 경우를 생각해보자
    • 로그인 토큰 혹은 로그인 정보 로컬 저장
    • 서버와의 접속 방식
    • 기타 중요하다고 생각하는 비즈니스 로직
  • 필요하지 않은 경우
    • 인증을 동적으로 받아 처리하는 경우 (브라우저처럼)

  Electron 에서 해야하는 가장 기본적인 처리

  • asar 처리
    • asar pack
    • asar extract
    • 사실상 보안의 의미는 없다.
  • uglify
    • 자바스크립트라면 기본
    • beautify
  • 이런 것들로는 절대로 보호할 수 없다.

  enclosejs

  • Igor Klopov
  • Use cases
    • 소스 없이 상용 제품을 납품하도록 노드 프로젝트를 바이너리 실행파일로 만들어준다.
  • 유료 제품
  • 코드가 공개되지 않아 추측만...

  enclosejs.com

  2017.05.03 - enclosejs 서비스 중단

  zeit/pkg

  zeit/pkg - Igor is here !

  zeit/pkg - 무엇인가 ?

  • Node.js 프로젝트를 패키징해서, Node.js가 설치되지 않은 장치에서도 실행할 수있는 실행 파일을 만들어주는 커맨드라인 인터페이스
  • 유스케이스
    • 소스없이 응용 프로그램의 상업용 버전 만들기
    • 소스없이 앱의 데모 / 평가판 만들기
    • 다른 플랫폼을위한 실행 파일을 만든다 (크로스 컴파일).
    • 일종의 자동 압축 해제 아카이브 또는 설치 프로그램 만들기
    • 패키지 애플리케이션을 실행하기 위해 Node.js 및 npm을 설치할 필요가 없음
    • 단일 파일로 배포되기 때문에 응용 프로그램을 배포하기 위해 npm install을 통해 수백 개의 파일을 다운로드 할 필요가 없다.
    • 자산을 실행 파일에 넣어두면 훨씬 더 이식성을 높일 수 있다.
    • 앱을 설치하지 않고 새로운 Node.js 버전에서 앱을 테스트

  npm i pkg -g

  pkg --help

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

*/

  pkg index.js

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

*/

  pkg .

  process 의 분리와 node-ipc (20:00)

  • pkg 로 패키징된 바이너리 실행 파일을 준비합니다.
  • 준비된 실행파일을 일렉트론의 메인 프로세스에서 실행합니다.
  • 그리고 일렉트론 메인과 실행파일을 node-ipc 통신으로 처리합니다.
  • 바이너리 파일에 있는 내용은 보호할 수 있게 됩니다.
  • 아쉽지만, ipc 에 관련된 처리 코드가 많이 추가됩니다.

  node-ipc, ipcMain, ipcRenderer

Renderer

main

binary

node-ipc

 ipcMain

ipcRenderer

TunnelBridge

TunnelClient

TunnelServer

  코드 조각들로 살펴보기 - electron main (20:05)

  • Only macOS : 최소 구현 코드 보기 쉽게
    • process.platform 분기 처리 귀찮
  • electron main 에서 'spawn' 을 이용하여 프로세스를 띄움.
    • 초기값을 electron 에서 프로세스로 보내야 할 경우 'args' 로 전달
    • 환경 변수가 추가되어 전달해야한다. 
  • electron main 을 'node-ipc' 의 클라이언트로 처리
  • 띄운 프로세스를 'node-ipc' 의 서버로 처리
  • 랜더러 프로세스와 'ipcMain, ipcRenderer' / 'node-ipc' 서버를 연결
  • 메인 프로세스와 'node-ipc' 서버를 연결

  electron main

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();
    });
});

  electron main - ServerLoader.ts

// 개발 모드
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;
}

  electron main - TunnelBridge.ts

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];
        }
    });
}

  electron main - TunnelBridge.ts

// 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);
        }
    });
}

  electron main - TunnelBridge.ts

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];
    });
}

  electron main - MainToServerChannel.ts

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});
    }
}

  electron main - PuppetMaster.ts

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;
    });
}

  electron main - DialogPuppet.ts

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);
            });
        });
    }
}

  코드 조각들로 살펴보기 - server (20:10)

  • 일렉트론의 랜더러 프로세스 하나하나를 별개의 'context' 로 관리
  • server 의 시작과 함께 'ContextManager' 인스턴스를 만든다.
  • 'node-ipc' 서버 설정을 한다.
    • TunnelServer
    • 메인 연결 채널을 만든다.
  • context 를 만든다.

  server - main.ts

(async function () {
    let contextManager: ContextManager = null;

    try {
        contextManager = new ContextManager();
        const ignored = contextManager.start();
    } catch (e) {
        console.log(e);
    }
}());

  server - ContextManager.ts

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);
}

  server - TunnelServer.ts

// 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);
    });
}

  server - ServerToMainChannel.ts

// 만들어진 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);
    });
}

  server - DelegatedChannel.ts

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`);
        }
    }
}

  server - Context.ts

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();
}

  코드 조각들로 살펴보기 - electron renderer (20:15)

  • 랜더러 프로세스가 시작되면, ipcRenderer 를 이용하여 연결
  • contextId 를 이용하여, 터널로 연결을 요청하고, 확인 받음.
  • 연결되면, 터널로 server 프로세스에세 메세지를 보내고 받을 수 있음.

  electron renderer - app.ts

$(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);
    }
});

  electron renderer - TunnelClient.ts

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);
    }
}

  코드 조각들로 살펴보기 - 패키징 (20:20)

  • 타입스크립트 빌드
  • pkg 를 이용해서 바이너리를 만들기
  • electron-builder 로 실행파일 만들기

  pack - package.json

{
  "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"
  ]
}

  pack - pack.js

// 타입스크립트 컴파일
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();

  pack - npm run pack

  Show Package Contents

  단점

  • 코드가 많아진다.
    • 코드 관리가 어렵다. => 타입스크립트 만세
  • 용량도 커진다.
    • 원래 엄청 크다.
  • 엔진 성능 이슈
    • pkg 한 것이 성능이 좋지 않다.
    • https://github.com/zeit/pkg/issues/74
  • ipc 성능 이슈
    • 물론 같은 컨텍스트보다는 느리다.
    • 실제로는 브라우저에서의 성능이 더 관건이다.

  pkg 들여다보기

  • enclosejs 와 달리 오픈소스라 분석이 가능함.
  • main 에서 직접 바이너리를 불러 일렉트론의 메인 컨텍스트에서 실행하는 것을 목표로 함.
  • 사실 zeit 도 일렉트론을 사용하기 때문에, 조만간 pkg 에서 공식 지원할 가능성도 있음.
  • 바이너리를 만드는 방법
  • 사용하는 방법
  • 결국 바이너리를 일렉트론의 메인에서 사용할 수 있도록 바이너리 만드는 부분을 커스터마이징 하고, 그 만들어진 바이너리를 일렉트론 메인에서 가져다 사용할 수 있도록 처리하는 부분을 커스터마이징 하는 것이 해야할 일
/* 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 };
}

  바이너리 만들기 - pkg/lib/packer.js

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();
  }
}

  바이너리 사용하기 - pkg/lib/fabricator.js

Protecting JavaScript Source Code in Electron

By Woongjae Lee

Protecting JavaScript Source Code in Electron

Seoul.js 미트업 20170818

  • 5,214