├── .gitignore ├── LICENSE ├── README.md ├── bin └── yackage.js ├── lib ├── dist.js ├── filters.js ├── init.js ├── mac.js ├── main.js ├── util.js └── win.js ├── package.json ├── resources ├── icon.icns └── icon.png └── templates └── basic ├── .gitignore ├── LICENSE └── lib ├── app-menu.js ├── gc.js ├── main.js ├── window-controller.js └── window.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.zip 3 | 4 | yarn.lock 5 | package-lock.json 6 | npm-debug.log 7 | node_modules 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yackage 2 | 3 | Package your Node.js project into an executable. 4 | 5 | This project is targeted for apps built with [Yue library][yue] and 6 | [Yode Node.js runtime][yode]. 7 | 8 | ## Install 9 | 10 | ``` 11 | npm i -g yackage 12 | ``` 13 | 14 | ## Usage 15 | 16 | ``` 17 | Usage: yackage [options] [command] 18 | 19 | Options: 20 | 21 | --platform Target platform (default: $npm_config_platform || 22 | process.platform) 23 | --arch Target arch (default: $npm_config_arch || 24 | process.arch) 25 | --app-dir Path to the source code dir of app (default: 26 | current working dir) 27 | --unpack Files to ignore when generating asar package 28 | (default: *.node) 29 | --unpack-dir Dirs to ignore when generating asar package 30 | --no-minify Do not minify the JavaScript source code 31 | --extra-info-plist The extra string to insert into the Info.plist 32 | --identity The identity used for signing app bundle on macOS 33 | --entitlements Path to the entitlements file when signing app 34 | bundle on macOS 35 | 36 | Commands: 37 | 38 | init Create an empty project under current directory 39 | build Build app bundle 40 | dist Build app bundle and generate app distribution 41 | dirty-build Build app bundle without reinstalling modules 42 | ``` 43 | 44 | Note that before using Yackage, the target app must have `fetch-yode` listed 45 | as a dependency. 46 | 47 | ## Configuration 48 | 49 | Configure your project by adding following fields to `package.json`: 50 | 51 | ```json 52 | { 53 | "main": "app.js", 54 | "build": { 55 | "appId": "com.app.id" 56 | "productName": "App" 57 | "copyright": "Copyright © 2020 Company", 58 | "minify": true, 59 | "unpack": "+(*.node|*.png)", 60 | "unpackDir": "{assets,build}", 61 | "ignore": [ "assets/*" ], 62 | "icons": { 63 | "win": "assets/icon.ico", 64 | "mac": "assets/icon.icns" 65 | }, 66 | "extraInfoPlist": "LSUIElement" 67 | } 68 | } 69 | ``` 70 | 71 | ## Examples 72 | 73 | Generate executable from the app under current working directory: 74 | 75 | ```sh 76 | cd /path/to/app/ 77 | yackage build out 78 | ``` 79 | 80 | Generate executable from path under arbitrary path: 81 | 82 | ``` 83 | yackage build out --app-dir /path/to/app 84 | ``` 85 | 86 | Generate executable for arbitrary platform: 87 | 88 | ``` 89 | yackage build out --platform win32 --arch ia32 90 | ``` 91 | 92 | Generate distributions: 93 | 94 | ``` 95 | yackage dist out --app-dir /path/to/app 96 | ``` 97 | 98 | ## How yackage works 99 | 100 | 1. Run `npm pack` to generate tarball for the app. 101 | 2. Extract the tarball to temporary directory and run `npm install`. 102 | 3. Use `asar` to pacakge the app and its dependencies. 103 | 4. Append the generated ASAR archive to Yode. 104 | 5. Yode would automatically recognize the ASAR archive appended in the 105 | executable and start with it. 106 | 107 | ### Differences from packaging in Electron 108 | 109 | By default yackage would unpack the `.node` files so they are not extracted 110 | dynamically when running, otherwise anti-virus softwares would complain. 111 | 112 | The unpacked files are placed in the `res` directory instead of the usual 113 | `.unpacked` directory, so the final distribution would look more formal. 114 | 115 | The `.js` files are compressed with `uglify-js` by default. 116 | 117 | The virutal root directory of ASAR archive is `${process.execPath}/asar`. Using 118 | `process.execPath` as virutal root directory directly would confuse Node.js 119 | since it should be a file. 120 | 121 | ## License 122 | 123 | Public domain. 124 | 125 | [yue]: https://github.com/yue/yue 126 | [yode]: https://github.com/yue/yode 127 | [electron-builder]: https://www.electron.build/configuration/configuration 128 | -------------------------------------------------------------------------------- /bin/yackage.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const program = require('commander') 5 | 6 | const {spawn} = require('child_process') 7 | const {packageApp, packageCleanApp} = require('../lib/main') 8 | const {initProject} = require('../lib/init') 9 | const {createZip} = require('../lib/dist') 10 | const {getLatestYodeVersion} = require('../lib/util') 11 | 12 | async function parseOpts() { 13 | const opts = { 14 | platform: process.env.npm_config_platform || process.platform, 15 | arch: process.env.npm_config_arch || process.arch, 16 | appDir: process.cwd(), 17 | unpack: '*.node', 18 | minify: true, 19 | extraInfoPlist: '', 20 | } 21 | Object.assign(opts, program.opts()) 22 | opts.appDir = path.resolve(opts.appDir) 23 | return opts 24 | } 25 | 26 | async function init() { 27 | await initProject('basic', process.cwd()) 28 | } 29 | 30 | async function build(outputDir) { 31 | const opts = await parseOpts() 32 | await packageCleanApp(outputDir, opts.appDir, opts, opts.platform, opts.arch) 33 | } 34 | 35 | async function dist(outputDir) { 36 | const opts = await parseOpts() 37 | const target = await packageCleanApp( 38 | outputDir, opts.appDir, opts, opts.platform, opts.arch) 39 | await createZip(opts.appDir, opts.platform, opts.arch, target) 40 | } 41 | 42 | async function dirtyBuild(outputDir) { 43 | const opts = await parseOpts() 44 | await packageApp(outputDir, opts.appDir, opts, opts.platform, opts.arch) 45 | } 46 | 47 | program.version('v' + require('../package.json').version) 48 | .description('Package Node.js project into app bundle with Yode') 49 | .option('--platform ', 50 | 'Target platform (default: $npm_config_platform || process.platform)') 51 | .option('--arch ', 52 | 'Target arch (default: $npm_config_arch || process.arch)') 53 | .option('--app-dir ', 54 | 'Path to the source code dir of app (default: current working dir)') 55 | .option('--unpack ', 56 | 'Files to ignore when generating asar package (default: *.node)') 57 | .option('--unpack-dir ', 58 | 'Dirs to ignore when generating asar package') 59 | .option('--no-minify', 60 | 'Do not minify the JavaScript source code') 61 | .option('--extra-info-plist', 62 | 'The extra string to insert into the Info.plist') 63 | .option('--identity ', 64 | 'The identity used for signing app bundle on macOS') 65 | .option('--entitlements ', 66 | 'Path to the entitlements file when signing app bundle on macOS') 67 | 68 | program.command('init') 69 | .description('Create an empty project under current directory') 70 | .action(init) 71 | 72 | program.command('build ') 73 | .description('Build app bundle') 74 | .action(build) 75 | 76 | program.command('dist ') 77 | .description('Generate app distribution') 78 | .action(dist) 79 | 80 | program.command('dirty-build ') 81 | .description('Build app bundle without reinstalling modules') 82 | .action(dirtyBuild) 83 | 84 | program.parse(process.argv) 85 | 86 | if (process.argv.length == 2) 87 | program.outputHelp() 88 | 89 | process.on('unhandledRejection', error => { 90 | console.error(error.stack) 91 | if (error.stdout) 92 | console.error(error.stdout.toString()) 93 | if (error.stderr) 94 | console.error(error.stderr.toString()) 95 | process.exit(1) 96 | }) 97 | -------------------------------------------------------------------------------- /lib/dist.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const util = require('util') 3 | const path = require('path') 4 | const yazl = require('yazl') 5 | const glob = util.promisify(require('glob')) 6 | 7 | const {streamPromise} = require('./util') 8 | 9 | async function addDir(zip, dir) { 10 | const files = await glob(dir.replace(/\\/g, '/') + '/**', {absolute: true}) 11 | for (const f of files) { 12 | const stat = await fs.stat(f) 13 | if (!stat.isFile()) 14 | continue 15 | zip.addFile(f, path.join(path.basename(dir), path.relative(dir, f))) 16 | } 17 | } 18 | 19 | async function createZip(appDir, platform, arch, target) { 20 | const packageJson = await fs.readJson(path.join(appDir, 'package.json')) 21 | const zipName = `${packageJson.name}-v${packageJson.version}-${platform}-${arch}.zip` 22 | const zip = new yazl.ZipFile() 23 | const stream = zip.outputStream.pipe(fs.createWriteStream(zipName)) 24 | 25 | if (platform === 'darwin') { 26 | await addDir(zip, target) 27 | } else { 28 | zip.addFile(target, path.basename(target)) 29 | const resDir = path.resolve(target, '..', 'res') 30 | if (await fs.pathExists(resDir)) 31 | await addDir(zip, resDir) 32 | } 33 | zip.addFile(path.resolve(target, '..', 'LICENSE'), 'LICENSE') 34 | zip.end() 35 | 36 | await streamPromise(stream) 37 | return zipName 38 | } 39 | 40 | module.exports = {createZip} 41 | -------------------------------------------------------------------------------- /lib/filters.js: -------------------------------------------------------------------------------- 1 | const fixedFilterList = [ 2 | 'build', 3 | 'build/*', 4 | 'node_modules/fetch-yode', 5 | 'node_modules/fetch-yode/**/*', 6 | 'node_modules/.bin/yode', 7 | 'node_modules/.bin/yode.exe', 8 | ] 9 | 10 | const stockFilterList = [ 11 | 'node_modules/@types', 12 | 'node_modules/@types/**', 13 | '**/*.md', 14 | '**/*.ts', 15 | '**/*.map', 16 | '**/*.bak', 17 | '**/docs/**', 18 | '**/support/**', 19 | '**/test/**', 20 | '**/tests/**', 21 | '**/coverage/**', 22 | '**/examples/**', 23 | '**/.github/**', 24 | '**/.vscode/**', 25 | '**/.travis.yml', 26 | '**/.npmignore', 27 | '**/.editorconfig', 28 | '**/.jscs.json', 29 | '**/.jshintrc', 30 | '**/.nvmrc', 31 | '**/.eslintrc', 32 | '**/.eslintrc.json', 33 | '**/.eslintignore', 34 | '**/.uglifyjsrc.json', 35 | '**/.DS_Store', 36 | '**/tslint.json', 37 | '**/tsconfig.json', 38 | '**/Gruntfile.js', 39 | '**/bower.json', 40 | '**/package-lock.json', 41 | '**/badges.html', 42 | '**/test.html', 43 | '**/Makefile', 44 | '**/LICENSE', 45 | '**/License', 46 | '**/license', 47 | '**/TODO', 48 | ] 49 | 50 | module.exports = {fixedFilterList, stockFilterList} 51 | -------------------------------------------------------------------------------- /lib/init.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const os = require('os') 3 | const path = require('path') 4 | const fullname = require('fullname') 5 | const sortPJ = require('sort-package-json') 6 | const spawn = require('await-spawn') 7 | 8 | const {mergeDeep} = require('./util') 9 | 10 | function getDefaultPackageJson(name) { 11 | return { 12 | name, 13 | version: '0.1.0', 14 | main: 'lib/main.js', 15 | build: { 16 | appId: `org.${os.userInfo().username}.${name}`, 17 | productName: getProductName(name) 18 | }, 19 | scripts: { 20 | start: 'yode .', 21 | build: 'yackage build out', 22 | dist: 'yackage dist out' 23 | }, 24 | license: 'MIT', 25 | dependencies: {}, 26 | devDependencies: {}, 27 | } 28 | } 29 | 30 | function getProductName(name) { 31 | return name.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase()) 32 | .replace(/[-_]/g, ' ') 33 | } 34 | 35 | async function trimSpawn(cmd, args, options = {}) { 36 | return (await spawn(cmd, args, options)).toString().trim() 37 | } 38 | 39 | async function readGitInfo(targetDir) { 40 | const info = {} 41 | const originUrl = await trimSpawn('git', ['config', '--get', 'remote.origin.url'], {cwd: targetDir}) 42 | if (!originUrl) 43 | return info 44 | info.repository = {type: "git", url: originUrl} 45 | const match = originUrl.match(/github.com\/([\w-_]+)\/([\w-_]+)/) 46 | if (!match) 47 | return info 48 | const [_, org, repo] = match 49 | info.name = repo 50 | info.homepage = `https://github.com/${org}/${repo}` 51 | info.bugs = {url: `${info.homepage}/issues`} 52 | info.build = { 53 | appId: `org.${org}.${repo}`, 54 | productName: getProductName(repo), 55 | } 56 | return info 57 | } 58 | 59 | async function initProject(type, targetDir) { 60 | if (await fs.pathExists(path.join(targetDir, 'package.json'))) { 61 | console.error('The target directory already includes a project') 62 | return 63 | } 64 | if (!await fs.pathExists(path.join(targetDir, '.git'))) { 65 | console.error('The target directory must be git-initialized') 66 | return 67 | } 68 | const packageJson = getDefaultPackageJson(path.basename(targetDir)) 69 | await Promise.all([ 70 | (async () => { 71 | let name 72 | const year = new Date().getFullYear() 73 | await Promise.all([ 74 | fs.copy(path.join(__dirname, '..', 'templates', type), targetDir), 75 | (async () => { 76 | name = await fullname() 77 | packageJson.build.copyright = `Copyright © ${year} ${name}` 78 | })(), 79 | ]) 80 | const license = path.join(targetDir, 'LICENSE') 81 | const content = await fs.readFile(license) 82 | await fs.writeFile(license, content.toString().replace('[YEAR]', year).replace('[NAME]', name)) 83 | })(), 84 | (async () => { 85 | await fs.copy(path.join(__dirname, '..', 'resources'), path.join(targetDir, 'build')) 86 | })(), 87 | (async () => { 88 | try { 89 | mergeDeep(packageJson, await readGitInfo(targetDir)) 90 | } catch (error) {} 91 | })(), 92 | (async () => { 93 | packageJson.dependencies.gui = '^' + await trimSpawn('npm', ['show', 'gui', 'version']) 94 | })(), 95 | (async () => { 96 | packageJson.dependencies['fetch-yode'] = '^' + await trimSpawn('npm', ['show', 'fetch-yode', 'version']) 97 | })(), 98 | (async () => { 99 | const yackagePackageJson = await fs.readJson(path.join(__dirname, '..', 'package.json')) 100 | packageJson.devDependencies['yackage'] = '^' + yackagePackageJson.version 101 | })(), 102 | ]) 103 | const sortedPackageJson = sortPJ(packageJson, { 104 | sortOrder: ['name', 'version', 'main', 'build', 'scripts'] 105 | }) 106 | await fs.writeJson(path.join(targetDir, 'package.json'), sortedPackageJson, {spaces: 2}) 107 | } 108 | 109 | module.exports = {initProject} 110 | -------------------------------------------------------------------------------- /lib/mac.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs-extra') 3 | const spawn = require('await-spawn') 4 | 5 | const {ignoreError} = require('./util') 6 | 7 | const infoPlistTemplate = ` 8 | 9 | 10 | 11 | 12 | CFBundlePackageType 13 | APPL 14 | CFBundleIdentifier 15 | {{IDENTIFIER}} 16 | CFBundleDisplayName 17 | {{PRODUCT_NAME}} 18 | CFBundleName 19 | {{PRODUCT_NAME}} 20 | CFBundleExecutable 21 | {{NAME}} 22 | CFBundleVersion 23 | {{VERSION}} 24 | CFBundleShortVersionString 25 | {{VERSION}} 26 | CFBundleIconFile 27 | icon.icns 28 | LSMinimumSystemVersion 29 | 10.10.0 30 | NSHighResolutionCapable 31 | 32 | NSSupportsAutomaticGraphicsSwitching 33 | 34 | {{EXTRA}} 35 | 36 | 37 | `.trim() 38 | 39 | async function createBundle(appInfo, appDir, outputDir, exeFile) { 40 | const bundleDir = `${outputDir}/${appInfo.productName}.app` 41 | await fs.remove(bundleDir) 42 | await fs.ensureDir(`${bundleDir}/Contents/Resources`) 43 | const exeDir = `${bundleDir}/Contents/MacOS` 44 | await Promise.all([ 45 | (async () => { 46 | await fs.ensureDir(exeDir) 47 | await fs.rename(exeFile, `${exeDir}/${appInfo.name}`) 48 | })(), 49 | (async () => { 50 | const infoPlist = infoPlistTemplate.replace(/{{NAME}}/g, appInfo.name) 51 | .replace(/{{PRODUCT_NAME}}/g, appInfo.productName) 52 | .replace(/{{IDENTIFIER}}/g, appInfo.appId) 53 | .replace(/{{VERSION}}/g, appInfo.version) 54 | .replace(/{{EXTRA}}/g, appInfo.extraInfoPlist ? appInfo.extraInfoPlist : '') 55 | await fs.writeFile(`${bundleDir}/Contents/Info.plist`, infoPlist) 56 | })(), 57 | (async () => { 58 | const iconTarget = `${bundleDir}/Contents/Resources/icon.icns` 59 | let iconSource = `${appDir}/build/icon.icns` 60 | if (appInfo.icons?.mac) 61 | iconSource = path.join(appDir, appInfo.icons.mac) 62 | if (await fs.pathExists(iconSource)) 63 | await fs.copy(iconSource, iconTarget) 64 | else 65 | await fs.copy(path.resolve(__dirname, '..', 'resources', 'icon.icns'), iconTarget) 66 | })(), 67 | ignoreError(fs.rename(`${outputDir}/res`, `${exeDir}/res`)) 68 | ]) 69 | return bundleDir 70 | } 71 | 72 | function codeSign(appInfo, target) { 73 | const args = [ '--deep', '--timestamp' ] 74 | if (appInfo.entitlements) { 75 | args.push('--entitlements', appInfo.entitlements) 76 | // Enable hardened runtime when there is entitlements. 77 | args.push('--options', 'runtime') 78 | } 79 | args.push('--sign', appInfo.identity ?? '-') 80 | args.push(target) 81 | return spawn('codesign', args, {stdio: 'inherit'}) 82 | } 83 | 84 | // Implementation taken from https://github.com/vercel/pkg/pull/1164. 85 | // 86 | // The MIT License (MIT) 87 | // 88 | // Copyright (c) 2021 Vercel, Inc. 89 | // 90 | // Permission is hereby granted, free of charge, to any person obtaining a copy 91 | // of this software and associated documentation files (the "Software"), to deal 92 | // in the Software without restriction, including without limitation the rights 93 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 94 | // copies of the Software, and to permit persons to whom the Software is 95 | // furnished to do so, subject to the following conditions: 96 | // 97 | // The above copyright notice and this permission notice shall be included in all 98 | // copies or substantial portions of the Software. 99 | // 100 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 101 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 102 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 103 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 104 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 105 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 106 | // SOFTWARE. 107 | function parseCStr(buf) { 108 | for (let i = 0; i < buf.length; i += 1) { 109 | if (buf[i] === 0) { 110 | return buf.slice(0, i).toString(); 111 | } 112 | } 113 | } 114 | 115 | function patchCommand(type, buf, file) { 116 | // segment_64 117 | if (type === 0x19) { 118 | const name = parseCStr(buf.slice(0, 16)); 119 | 120 | if (name === '__LINKEDIT') { 121 | const fileoff = buf.readBigUInt64LE(32); 122 | const vmsize_patched = BigInt(file.length) - fileoff; 123 | const filesize_patched = vmsize_patched; 124 | 125 | buf.writeBigUInt64LE(vmsize_patched, 24); 126 | buf.writeBigUInt64LE(filesize_patched, 40); 127 | } 128 | } 129 | 130 | // symtab 131 | if (type === 0x2) { 132 | const stroff = buf.readUInt32LE(8); 133 | const strsize_patched = file.length - stroff; 134 | 135 | buf.writeUInt32LE(strsize_patched, 12); 136 | } 137 | } 138 | 139 | async function extendStringTableSize(target) { 140 | const file = await fs.readFile(target) 141 | 142 | const align = 8; 143 | const hsize = 32; 144 | 145 | const ncmds = file.readUInt32LE(16); 146 | const buf = file.slice(hsize); 147 | 148 | for (let offset = 0, i = 0; i < ncmds; i += 1) { 149 | const type = buf.readUInt32LE(offset); 150 | 151 | offset += 4; 152 | const size = buf.readUInt32LE(offset) - 8; 153 | 154 | offset += 4; 155 | patchCommand(type, buf.slice(offset, offset + size), file); 156 | 157 | offset += size; 158 | if (offset & align) { 159 | offset += align - (offset & align); 160 | } 161 | } 162 | 163 | await fs.writeFile(target, file) 164 | } 165 | 166 | module.exports = {createBundle, codeSign, extendStringTableSize} 167 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const util = require('util') 3 | const os = require('os') 4 | const asar = require('@electron/asar') 5 | const fs = require('fs-extra') 6 | const spawn = require('await-spawn') 7 | const tar = require('tar') 8 | const uglify = require('uglify-js') 9 | 10 | const {PassThrough, Transform} = require('stream') 11 | const {fixedFilterList, stockFilterList} = require('./filters') 12 | const {ignoreError, cat, getYode} = require('./util') 13 | 14 | const checkLicense = util.promisify(require('license-checker-rseidelsohn').init) 15 | 16 | // Minimize js file. 17 | function transform(rootDir, p) { 18 | if (!p.endsWith('.js')) 19 | return new PassThrough() 20 | let data = '' 21 | return new Transform({ 22 | transform(chunk, encoding, callback) { 23 | data += chunk 24 | callback(null) 25 | }, 26 | flush(callback) { 27 | const result = uglify.minify(data, {parse: {bare_returns: true}}) 28 | if (result.error) { 29 | const rp = path.relative(rootDir, p) 30 | const message = `${result.error.message} at line ${result.error.line} col ${result.error.col}` 31 | console.error(`Failed to minify ${rp}:`, message) 32 | callback(null, data) 33 | } else { 34 | callback(null, result.code) 35 | } 36 | } 37 | }) 38 | } 39 | 40 | // Parse the packageJson and generate app information. 41 | function getAppInfo(packageJson) { 42 | const appInfo = { 43 | name: packageJson.name, 44 | version: packageJson.version, 45 | description: packageJson.description, 46 | appId: `com.${packageJson.name}.${packageJson.name}`, 47 | productName: packageJson.build.productName 48 | } 49 | if (packageJson.build) 50 | Object.assign(appInfo, packageJson.build) 51 | if (!appInfo.copyright) 52 | appInfo.copyright = `Copyright © ${(new Date()).getYear()} ${appInfo.productName}` 53 | return appInfo 54 | } 55 | 56 | // Copy the app into a dir and install production dependencies. 57 | async function installApp(appDir, platform, arch) { 58 | const immediateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'yacakge-')) 59 | const freshDir = path.join(immediateDir, 'package') 60 | try { 61 | // cd appDir && npm pack 62 | const pack = await spawn('npm', ['pack'], {shell: true, cwd: appDir}) 63 | const tarball = path.join(appDir, pack.toString().trim().split('\n').pop()) 64 | try { 65 | // cd immediateDir && tar xf tarball 66 | await tar.x({file: tarball, cwd: immediateDir}) 67 | } finally { 68 | await ignoreError(fs.remove(tarball)) 69 | } 70 | // cd freshDir && npm install --production 71 | const env = Object.create(process.env) 72 | env.npm_config_platform = platform 73 | env.npm_config_arch = arch 74 | try { 75 | await spawn('npm', ['install', '--production'], {shell: true, cwd: freshDir, env}) 76 | } catch (error) { 77 | throw Error(`Failed to install app: \n${error.stderr}`) 78 | } 79 | } catch (error) { 80 | console.error('Package dir left for debug:', freshDir) 81 | throw error 82 | } 83 | return freshDir 84 | } 85 | 86 | // Append ASAR meta information at end of target. 87 | async function appendMeta(target) { 88 | const stat = await fs.stat(target) 89 | const meta = Buffer.alloc(8 + 1 + 4) 90 | const asarSize = stat.size + meta.length 91 | meta.writeDoubleLE(asarSize, 0) 92 | meta.writeUInt8(2, 8) 93 | meta.write('ASAR', 9) 94 | await fs.appendFile(target, meta) 95 | } 96 | 97 | // Creaet asar archive. 98 | async function createAsarArchive(appDir, outputDir, target, appInfo) { 99 | const asarOpts = { 100 | // Let glob search "**/*' under appDir, instead of passing appDir to asar 101 | // directly. In this way our filters can work on the source root dir. 102 | pattern: '**/*', 103 | transform: appInfo.minify ? transform.bind(this, appDir) : null, 104 | unpack: appInfo.unpack, 105 | unpackDir: appInfo.unpackDir, 106 | globOptions: { 107 | cwd: appDir, 108 | noDir: true, 109 | ignore: fixedFilterList.concat(stockFilterList), 110 | }, 111 | } 112 | // Do not include outputDir in the archive. 113 | let relativeOutputDir = path.isAbsolute(outputDir) ? path.relative(appDir, outputDir) 114 | : outputDir 115 | asarOpts.globOptions.ignore.push(outputDir) 116 | asarOpts.globOptions.ignore.push(outputDir + '/*') 117 | if (appInfo.ignore) 118 | asarOpts.globOptions.ignore.push(...appInfo.ignore); 119 | // Run asar under appDir to work around buggy glob behavior. 120 | const cwd = process.cwd() 121 | try { 122 | process.chdir(appDir) 123 | await asar.createPackageWithOptions('', target, asarOpts) 124 | } finally { 125 | process.chdir(cwd) 126 | } 127 | await appendMeta(target) 128 | } 129 | 130 | // Write the size of binary into binary. 131 | async function replaceOffsetPlaceholder(target) { 132 | const mark = '/* REPLACE_WITH_OFFSET */' 133 | const data = await fs.readFile(target) 134 | const pos = data.indexOf(Buffer.from(mark)) 135 | if (pos <= 0) 136 | return false 137 | const stat = await fs.stat(target) 138 | const replace = `, ${stat.size}`.padEnd(mark.length, ' ') 139 | data.write(replace, pos) 140 | await fs.writeFile(target, data) 141 | return true 142 | } 143 | 144 | // Collect licenses. 145 | async function writeLicenseFile(outputDir, appDir) { 146 | let license = '' 147 | const data = await checkLicense({start: appDir}) 148 | for (const name in data) { 149 | const info = data[name] 150 | if (!info.licenseFile) 151 | continue 152 | license += name + '\n' 153 | if (info.publisher) 154 | license += info.publisher + '\n' 155 | if (info.email) 156 | license += info.email + '\n' 157 | if (info.url) 158 | license += info.url + '\n' 159 | const content = await fs.readFile(info.licenseFile) 160 | license += '\n' + content.toString().replace(/\r\n/g, '\n') 161 | license += '\n' + '-'.repeat(70) + '\n\n' 162 | } 163 | await fs.writeFile(path.join(outputDir, 'LICENSE'), license) 164 | } 165 | 166 | async function packageApp(outputDir, appDir, options, platform, arch) { 167 | const appInfo = getAppInfo(await fs.readJson(path.join(appDir, 'package.json'))) 168 | Object.assign(appInfo, options) 169 | await fs.emptyDir(outputDir) 170 | let target = path.join(outputDir, platform === 'win32' ? `${appInfo.name}.exe` : appInfo.name) 171 | const intermediateAsar = path.resolve(outputDir, 'app.ear') 172 | const srcYodePath = getYode(appDir).path 173 | const yodePath = path.resolve(outputDir, path.basename(srcYodePath)) 174 | try { 175 | await Promise.all([ 176 | createAsarArchive(appDir, outputDir, intermediateAsar, appInfo), 177 | fs.copy(srcYodePath, yodePath), 178 | ]) 179 | if (platform === 'darwin') // remove existing signature 180 | await spawn('codesign', ['--remove-signature', yodePath]) 181 | else if (platform === 'win32') // add icon and exe info 182 | await require('./win').modifyExe(yodePath, appInfo, appDir) 183 | // Modify the offset placeholder inside binary. 184 | await replaceOffsetPlaceholder(yodePath) 185 | // Append asar file to the end of yode binary. 186 | await cat(target, yodePath, intermediateAsar) 187 | // Patch the executable to make it signable. 188 | if (platform === 'darwin') 189 | await require('./mac').extendStringTableSize(target) 190 | await Promise.all([ 191 | fs.chmod(target, 0o755), 192 | writeLicenseFile(outputDir, appDir), 193 | (async () => { 194 | const resDir = path.join(outputDir, 'res') 195 | await ignoreError(fs.remove(resDir)) 196 | await ignoreError(fs.rename(`${intermediateAsar}.unpacked`, resDir)) 197 | })(), 198 | ]) 199 | } finally { 200 | await ignoreError([ 201 | fs.remove(yodePath), 202 | fs.remove(intermediateAsar), 203 | fs.remove(`${intermediateAsar}.unpacked`), 204 | ]) 205 | } 206 | if (platform === 'darwin') { 207 | target = await require('./mac').createBundle(appInfo, appDir, outputDir, target) 208 | await require('./mac').codeSign(appInfo, target) 209 | } 210 | return target 211 | } 212 | 213 | async function packageCleanApp(outputDir, appDir, options, platform, arch) { 214 | const freshDir = await installApp(appDir, platform, arch) 215 | try { 216 | return await packageApp(outputDir, freshDir, options, platform, arch) 217 | } finally { 218 | await ignoreError(fs.remove(freshDir)) 219 | } 220 | } 221 | 222 | module.exports = {packageApp, packageCleanApp} 223 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const util = require('util') 4 | 5 | // Find out the fetch-yode module. 6 | function getYode(appDir) { 7 | try { 8 | return require(path.join(appDir, 'node_modules', 'fetch-yode')) 9 | } catch { 10 | throw new Error('The "fetch-yode" module must be a dependency of the app') 11 | } 12 | } 13 | 14 | // Ignore error of a Promise. 15 | function ignoreError(arg) { 16 | const promise = Array.isArray(arg) ? Promise.all(arg) : arg 17 | return promise.catch(() => {}) 18 | } 19 | 20 | // Concatenate files together. 21 | async function cat(output, ...args) { 22 | const write = fs.createWriteStream(output) 23 | for (const f of args) { 24 | const read = fs.createReadStream(f) 25 | read.pipe(write, { end: false }) 26 | await streamPromise(read) 27 | } 28 | await util.promisify(write.end).call(write) 29 | } 30 | 31 | // Turn stream into Promise. 32 | function streamPromise(stream) { 33 | return new Promise((resolve, reject) => { 34 | stream.on('end', () => { 35 | resolve('end') 36 | }) 37 | stream.on('finish', () => { 38 | resolve('finish') 39 | }) 40 | stream.on('error', (error) => { 41 | reject(error) 42 | }) 43 | }) 44 | } 45 | 46 | // Recursive version of Object.assign. 47 | const isObject = obj => obj && typeof obj === 'object' 48 | function mergeDeep(target, ...sources) { 49 | if (!sources.length) 50 | return target 51 | const source = sources.shift() 52 | if (isObject(target) && isObject(source)) { 53 | for (const key in source) { 54 | if (isObject(source[key])) { 55 | if (!target[key]) 56 | Object.assign(target, { [key]: {} }) 57 | mergeDeep(target[key], source[key]) 58 | } else { 59 | Object.assign(target, { [key]: source[key] }) 60 | } 61 | } 62 | } 63 | return mergeDeep(target, ...sources) 64 | } 65 | 66 | module.exports = {getYode, ignoreError, cat, streamPromise, mergeDeep} 67 | -------------------------------------------------------------------------------- /lib/win.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const path = require('path') 3 | const util = require('util') 4 | const rcedit = require('rcedit') 5 | 6 | async function modifyExe(exePath, appInfo, appDir) { 7 | const options = { 8 | 'version-string': { 9 | FileDescription: appInfo.description, 10 | ProductName: appInfo.productName, 11 | LegalCopyright: appInfo.copyright, 12 | }, 13 | 'file-version': appInfo.version, 14 | 'product-version': appInfo.version, 15 | } 16 | let iconPath = `${appDir}/build/icon.ico` 17 | if (appInfo.icons?.win) 18 | iconPath = path.join(appDir, appInfo.icons.win) 19 | if (await fs.pathExists(iconPath)) 20 | options['icon'] = iconPath 21 | return await rcedit(exePath, options) 22 | } 23 | 24 | module.exports = {modifyExe} 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yackage", 3 | "version": "0.9.2", 4 | "main": "./lib/main.js", 5 | "description": "Build native desktop apps with Node.js", 6 | "bin": { 7 | "yackage": "./bin/yackage.js" 8 | }, 9 | "engines": { 10 | "node": ">=8" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/yue/yackage.git" 15 | }, 16 | "license": "Unlicense", 17 | "dependencies": { 18 | "@electron/asar": "3.2.3", 19 | "await-spawn": "4.0.2", 20 | "commander": "8.3.0", 21 | "fs-extra": "10.0.0", 22 | "fullname": "4.0.1", 23 | "glob": "7.1.2", 24 | "license-checker-rseidelsohn": "2.2.0", 25 | "rcedit": "3.0.1", 26 | "sort-package-json": "1.53.1", 27 | "tar": "6.1.11", 28 | "uglify-js": "3.17.4", 29 | "yazl": "2.5.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yue/yackage/3c3e57a5f21be6b917db5890521ad478d8533a41/resources/icon.icns -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yue/yackage/3c3e57a5f21be6b917db5890521ad478d8533a41/resources/icon.png -------------------------------------------------------------------------------- /templates/basic/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.zip 3 | 4 | yarn.lock 5 | package-lock.json 6 | npm-debug.log 7 | /node_modules/ 8 | /out/ 9 | -------------------------------------------------------------------------------- /templates/basic/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [YEAR] [NAME] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /templates/basic/lib/app-menu.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | class AppMenu { 4 | constructor(win) { 5 | if (process.platform != 'darwin') 6 | this.win = win 7 | 8 | const menus = [] 9 | 10 | const quitMenu = [ 11 | { 12 | label: 'Collect Garbage', 13 | accelerator: 'CmdOrCtrl+Shift+G', 14 | onClick() { process.gc(true) }, 15 | }, 16 | { type: 'separator' }, 17 | { 18 | label: 'Quit', 19 | accelerator: 'CmdOrCtrl+Q', 20 | onClick() { require('./window-controller').quit() }, 21 | }, 22 | ] 23 | 24 | if (process.platform == 'darwin') { 25 | menus.push({ 26 | label: require('../package.json').build.productName, 27 | submenu: quitMenu.concat([ 28 | { type: 'separator' }, 29 | { role: 'hide' }, 30 | { role: 'hide-others' }, 31 | { type: 'separator' }, 32 | ]), 33 | }) 34 | } 35 | 36 | menus.push({ 37 | label: 'File', 38 | submenu: [ 39 | { role: 'close-window' }, 40 | ], 41 | }) 42 | 43 | if (process.platform == 'darwin') { 44 | menus.push({ 45 | label: 'Edit', 46 | submenu: [ 47 | { role: 'undo' }, 48 | { role: 'redo' }, 49 | { type: 'separator' }, 50 | { role: 'cut' }, 51 | { role: 'copy' }, 52 | { role: 'paste' }, 53 | { role: 'select-all' }, 54 | ], 55 | }) 56 | } else { 57 | menus[0].submenu = menus[0].submenu.concat(quitMenu) 58 | } 59 | 60 | if (process.platform == 'darwin') { 61 | menus.push({ 62 | label: 'Window', 63 | role: 'window', 64 | submenu: [ 65 | { role: 'minimize' }, 66 | { role: 'maximize' }, 67 | ], 68 | }) 69 | } 70 | 71 | this.menu = gui.MenuBar.create(menus) 72 | } 73 | } 74 | 75 | module.exports = AppMenu 76 | 77 | -------------------------------------------------------------------------------- /templates/basic/lib/gc.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | // API to hint garbage collection. 4 | let gcTimer = null 5 | process.gc = (immediate=false, level=1) => { 6 | if (gcTimer) 7 | clearTimeout(gcTimer) 8 | if (!immediate) { // gc after action can cause lagging. 9 | gcTimer = setTimeout(process.gc.bind(null, true, level), 5 * 1000) 10 | return 11 | } 12 | gui.memoryPressureNotification(level) 13 | } 14 | 15 | // Run gc every 5 minutes. 16 | setInterval(process.gc.bind(null), 5 * 60 * 1000) 17 | -------------------------------------------------------------------------------- /templates/basic/lib/main.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | require('./gc') 4 | 5 | if (process.platform == 'darwin') 6 | gui.lifetime.onReady = main 7 | else 8 | main() 9 | 10 | function main() { 11 | if (process.platform != 'darwin') { 12 | const packageJson = require('../package.json') 13 | gui.app.setName(packageJson.build.productName) 14 | gui.app.setID(packageJson.build.appId) 15 | } 16 | 17 | const windowController = require('./window-controller') 18 | windowController.create() 19 | } 20 | 21 | if (!process.versions.yode) { 22 | gui.MessageLoop.run() 23 | process.exit(0) 24 | } 25 | -------------------------------------------------------------------------------- /templates/basic/lib/window-controller.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | const AppMenu = require('./app-menu') 4 | const Window = require('./window') 5 | 6 | class WindowController { 7 | constructor() { 8 | if (process.platform == 'darwin') { 9 | this.appMenu = new AppMenu() 10 | gui.app.setApplicationMenu(this.appMenu.menu) 11 | } 12 | this.windows = new Set() 13 | } 14 | 15 | create() { 16 | const win = new Window() 17 | win.window.center() 18 | win.window.activate() 19 | win.window.onClose = () => { 20 | this.windows.delete(win) 21 | this.quitIfAllClosed() 22 | } 23 | this.windows.add(win) 24 | } 25 | 26 | quitIfAllClosed() { 27 | if (process.platform == 'darwin') 28 | return 29 | if (this.windows.size == 0) 30 | this.quit() 31 | } 32 | 33 | quit() { 34 | gui.MessageLoop.quit() 35 | process.exit(0) 36 | } 37 | } 38 | 39 | module.exports = new WindowController 40 | -------------------------------------------------------------------------------- /templates/basic/lib/window.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | const AppMenu = require('./app-menu') 4 | 5 | class Window { 6 | constructor() { 7 | this.window = gui.Window.create({}) 8 | this.window.setContentSize({width: 400, height: 400}) 9 | this.window.setContentView(gui.Label.create('Hello World')) 10 | if (process.platform != 'darwin') { 11 | this.menu = new AppMenu(this) 12 | this.window.setMenuBar(this.menu.menu) 13 | } 14 | } 15 | } 16 | 17 | module.exports = Window 18 | --------------------------------------------------------------------------------