Quellcode durchsuchen

feat(Builder): support nsis diff updater building (#12)

Evshiron Magicka vor 8 Jahren
Ursprung
Commit
8f2f91433a

+ 3 - 0
assets/project/package.json

@@ -25,6 +25,9 @@
     "mac": {
       "displayName": "Project",
       "copyright": "copyright"
+    },
+    "nsis": {
+      "diffUpdaters": true
     }
   },
   "devDependencies": {

+ 7 - 0
docs/Options.md

@@ -40,3 +40,10 @@ icon | string | .icns icon file. Defaults to `undefined`.
 ## build.linux <- [LinuxConfig](../src/lib/LinuxConfig.ts)
 
 Currently noop.
+
+## build.nsis <- [NsisConfig](../src/lib/NsisConfig.ts)
+
+Name | Type | Description
+--- | --- | ---
+diffUpdaters | boolean | Whether to build diff updaters. Defaults to `false`.
+hashCalculation | boolean | Whether to calculate hashes for installers and updaters. Defaults to `true`.

+ 3 - 0
package.json

@@ -30,6 +30,7 @@
     "@types/node": "^7.0.5",
     "@types/progress": "^1.1.28",
     "@types/request": "0.0.41",
+    "@types/semver": "^5.3.31",
     "@types/tmp": "0.0.32",
     "@types/yargs": "^6.6.0",
     "ava": "^0.18.2",
@@ -42,6 +43,7 @@
   "dependencies": {
     "7zip-bin": "^2.0.4",
     "debug": "^2.6.1",
+    "dir-compare": "^1.3.0",
     "fs-extra-promise": "^0.4.1",
     "globby": "^6.1.0",
     "plist": "^2.0.1",
@@ -49,6 +51,7 @@
     "rcedit": "^0.8.0",
     "request": "^2.80.0",
     "request-progress": "^3.0.0",
+    "semver": "^5.3.0",
     "source-map-support": "^0.4.11",
     "tmp": "0.0.31",
     "yargs": "^7.0.1"

+ 2 - 0
src/lib/BuildConfig.ts

@@ -4,6 +4,7 @@ import { normalize } from 'path';
 import { WinConfig } from './WinConfig';
 import { MacConfig } from './MacConfig';
 import { LinuxConfig } from './LinuxConfig';
+import { NsisConfig } from './NsisConfig';
 
 export class BuildConfig {
 
@@ -19,6 +20,7 @@ export class BuildConfig {
     public win: WinConfig = new WinConfig();
     public mac: MacConfig = new MacConfig();
     public linux: LinuxConfig = new LinuxConfig();
+    public nsis: NsisConfig = new NsisConfig();
 
     public appId: string = undefined;
     public ffmpegIntegration: boolean = false;

+ 58 - 1
src/lib/Builder.ts

@@ -1,6 +1,7 @@
 
 import { dirname, basename, join, resolve } from 'path';
 
+import * as semver from 'semver';
 import { ensureDirAsync, emptyDir, readFileAsync, readJsonAsync, writeFileAsync, copyAsync, removeAsync, createReadStream, createWriteStream, renameAsync } from 'fs-extra-promise';
 
 const debug = require('debug')('build:builder');
@@ -12,7 +13,8 @@ import { Downloader } from './Downloader';
 import { FFmpegDownloader } from './FFmpegDownloader';
 import { extractGeneric, compress } from './archive';
 import { BuildConfig } from './BuildConfig';
-import { NsisComposer, nsisBuild } from './nsis-gen';
+import { NsisVersions } from './NsisVersions';
+import { NsisComposer, NsisDiffer, nsisBuild } from './nsis-gen';
 import { mergeOptions, findExecutable, findFFmpeg, findRuntimeRoot, findExcludableDependencies, tmpName, tmpFile, tmpDir, cpAsync } from './util';
 
 interface IBuilderOptions {
@@ -335,6 +337,44 @@ export class Builder {
 
     }
 
+    protected async buildNsisDiffUpdater(platform: string, arch: string, versions: NsisVersions, fromVersion: string, toVersion: string, pkg: any, config: BuildConfig) {
+
+        const diffNsis = resolve(this.dir, config.output, `${ pkg.name }-${ toVersion }-from-${ fromVersion }-${ platform }-${ arch }-Update.exe`);
+
+        const fromDir = resolve(this.dir, config.output, (await versions.getVersion(fromVersion)).source);
+        const toDir = resolve(this.dir, config.output, (await versions.getVersion(toVersion)).source);
+
+        const data = await (new NsisDiffer(fromDir, toDir, {
+
+            // Basic.
+            appName: config.win.versionStrings.ProductName,
+            companyName: config.win.versionStrings.CompanyName,
+            description: config.win.versionStrings.FileDescription,
+            version: config.win.productVersion,
+            copyright: config.win.versionStrings.LegalCopyright,
+
+            // Compression.
+            compression: 'lzma',
+            solid: true,
+
+            // Output.
+            output: diffNsis,
+
+        })).make();
+
+        const script = await tmpName();
+        await writeFileAsync(script, data);
+
+        await nsisBuild(script, {
+            mute: false,
+        });
+
+        await removeAsync(script);
+
+        await versions.addUpdater(toVersion, fromVersion, arch, diffNsis);
+
+    }
+
     protected async buildDirTarget(platform: string, arch: string, runtimeDir: string, pkg: any, config: BuildConfig): Promise<string> {
 
         const targetDir = join(this.dir, config.output, `${ pkg.name }-${ pkg.version }-${ platform }-${ arch }`);
@@ -418,6 +458,8 @@ export class Builder {
             return;
         }
 
+        const versions = new NsisVersions(resolve(this.dir, config.output, 'versions.nsis.json'));
+
         const targetNsis = resolve(dirname(targetDir), `${ basename(targetDir) }-Setup.exe`);
 
         const data = await (new NsisComposer({
@@ -450,6 +492,21 @@ export class Builder {
 
         await removeAsync(script);
 
+        await versions.addVersion(pkg.version, '', targetDir);
+        await versions.addInstaller(pkg.version, arch, targetNsis);
+
+        if(config.nsis.diffUpdaters) {
+
+            for(const version of await versions.getVersions()) {
+                if(semver.gt(pkg.version, version)) {
+                    await this.buildNsisDiffUpdater(platform, arch, versions, version, pkg.version, pkg, config);
+                }
+            }
+
+        }
+
+        await versions.save();
+
     }
 
     protected async buildTask(platform: string, arch: string, pkg: any, config: BuildConfig) {

+ 21 - 0
src/lib/NsisConfig.ts

@@ -0,0 +1,21 @@
+
+export class NsisConfig {
+
+    public diffUpdaters: boolean = false;
+    public hashCalculation: boolean = true;
+
+    constructor(options: any = {}) {
+
+        Object.keys(this).map((key) => {
+            if(options[key] !== undefined) {
+                switch(key) {
+                default:
+                    (<any>this)[key] = options[key];
+                    break;
+                }
+            }
+        });
+
+    }
+
+}

+ 190 - 0
src/lib/NsisVersions.ts

@@ -0,0 +1,190 @@
+
+import { basename, dirname, relative } from 'path';
+import { createHash } from 'crypto';
+
+import { exists, readJsonAsync, writeJsonAsync, createReadStream } from 'fs-extra-promise';
+import * as semver from 'semver';
+
+interface IInstaller {
+    arch: string;
+    path: string;
+    hash: string;
+    created: number;
+}
+
+interface IUpdater {
+    arch: string;
+    fromVersion: string;
+    path: string;
+    hash: string;
+    created: number;
+}
+
+interface IVersion {
+    version: string;
+    changelog: string;
+    source: string;
+    installers: IInstaller[];
+    updaters: IUpdater[];
+}
+
+interface IData {
+    latest: string;
+    versions: IVersion[];
+}
+
+export class NsisVersions {
+
+    protected outputDir: string;
+    protected data: IData;
+
+    constructor(protected path: string) {
+
+        this.outputDir = dirname(path);
+
+    }
+
+    public async addVersion(version: string, changelog: string, source: string) {
+
+        const data = await this.getData();
+
+        if(!data.versions.find(item => item.version == version)) {
+
+            data.versions.push({
+                version,
+                changelog,
+                source: basename(source),
+                installers: [],
+                updaters: [],
+            });
+
+        }
+
+        this.updateLatestVersion();
+
+    }
+
+    public async getVersions(): Promise<string[]> {
+
+        const data = await this.getData();
+
+        return data.versions.map(item => item.version);
+
+    }
+
+    public async getVersion(version: string): Promise<IVersion> {
+
+        const data = await this.getData();
+
+        const item = data.versions.find(item => item.version == version);
+
+        if(!item) {
+            throw new Error('ERROR_VERSION_NOT_FOUND');
+        }
+
+        return item;
+
+    }
+
+    public async addInstaller(version: string, arch: string, path: string) {
+
+        const data = await this.getData();
+
+        const versionItem: IVersion = data.versions.find(item => item.version == version);
+
+        if(!versionItem) {
+            throw new Error('ERROR_VERSION_NOT_FOUND');
+        }
+
+        if(!versionItem.installers.find(item => item.arch == arch)) {
+
+            versionItem.installers.push({
+                arch,
+                path: relative(this.outputDir, path),
+                hash: await this.hashFile('sha256', path),
+                created: Date.now(),
+            });
+
+        }
+
+    }
+
+    public async addUpdater(version: string, fromVersion: string, arch: string, path: string) {
+
+        const data = await this.getData();
+
+        const versionItem: IVersion = data.versions.find(item => item.version == version);
+
+        if(!versionItem) {
+            throw new Error('ERROR_VERSION_NOT_FOUND');
+        }
+
+        if(!versionItem.updaters.find(item => item.fromVersion == fromVersion && item.arch == arch)) {
+
+            versionItem.updaters.push({
+                fromVersion,
+                arch,
+                path: relative(this.outputDir, path),
+                hash: await this.hashFile('sha256', path),
+                created: Date.now(),
+            });
+
+        }
+
+    }
+
+    public async save() {
+        await writeJsonAsync(this.path, this.data);
+    }
+
+    protected async getData() {
+
+        if(!this.data) {
+            this.data = (await new Promise((resolve, reject) => exists(this.path, resolve)))
+            ? await readJsonAsync(this.path)
+            : {
+                latest: undefined,
+                versions: [],
+            };
+        }
+
+        return this.data;
+
+    }
+
+    protected updateLatestVersion() {
+
+        if(this.data.versions.length == 0) {
+            return;
+        }
+
+        const versions = [ ...this.data.versions ];
+        versions.sort((a, b) => semver.gt(a.version, b.version) ? -1 : 1);
+
+        this.data.latest = versions[0].version;
+
+    }
+
+    protected hashFile(type: string, path: string): Promise<string> {
+        return new Promise((resolve, reject) => {
+
+            const hasher = createHash(type);
+
+            hasher.on('error', reject);
+            hasher.on('readable', () => {
+
+                const data = hasher.read();
+
+                if(data) {
+                    hasher.end();
+                    resolve((<any>data).toString('hex'));
+                }
+
+            });
+
+            createReadStream(path).pipe(hasher);
+
+        });
+    }
+
+}

+ 29 - 2
src/lib/nsis-gen/NsisComposer.ts

@@ -5,7 +5,7 @@ import { readdirAsync, lstatAsync } from 'fs-extra-promise';
 
 const globby = require('globby');
 
-interface INsisComposerOptions {
+export interface INsisComposerOptions {
 
     // Basic.
     appName: string;
@@ -19,7 +19,7 @@ interface INsisComposerOptions {
     solid: boolean;
 
     // Files.
-    srcDir: string;
+    srcDir?: string;
 
     // Output.
     output: string;
@@ -34,6 +34,29 @@ export class NsisComposer {
 
     constructor(protected options: INsisComposerOptions) {
 
+        if(!this.options.appName) {
+            throw new Error('ERROR_NO_APPNAME');
+        }
+
+        if(!this.options.companyName) {
+            throw new Error('ERROR_NO_COMPANYNAME');
+        }
+
+        if(!this.options.description) {
+            throw new Error('ERROR_NO_DESCRIPTION');
+        }
+
+        if(!this.options.version) {
+            throw new Error('ERROR_NO_VERSION');
+        }
+
+        if(!this.options.copyright) {
+            throw new Error('ERROR_NO_COPYRIGHT');
+        }
+
+        this.options.compression = this.options.compression || 'lzma';
+        this.options.solid = this.options.solid ? true : false;
+
         this.fixedVersion = this.fixVersion(this.options.version);
 
     }
@@ -133,6 +156,10 @@ SectionEnd
 
     protected async makeInstallerFiles(): Promise<string> {
 
+        if(!this.options.srcDir) {
+            throw new Error('ERROR_NO_SRCDIR');
+        }
+
         const out: string[] = [];
         await this.readdirLines(resolve(this.options.srcDir), resolve(this.options.srcDir), out);
 

+ 56 - 0
src/lib/nsis-gen/NsisDiffer.ts

@@ -0,0 +1,56 @@
+
+import { dirname, join, relative, resolve, win32 } from 'path';
+
+const dircompare = require('dir-compare');
+
+import { NsisComposer, INsisComposerOptions } from './NsisComposer';
+
+export class NsisDiffer extends NsisComposer {
+
+    constructor(protected fromDir: string, protected toDir: string, options: INsisComposerOptions) {
+        super(options);
+
+    }
+
+    // Overrided, https://github.com/Microsoft/TypeScript/issues/2000.
+    protected async makeInstallerFiles(): Promise<string> {
+
+        const result = await dircompare.compare(this.fromDir, this.toDir, {
+            compareSize: true,
+            compareDate: true,
+        });
+
+        const lines: string[] = [];
+
+        for(const diff of result.diffSet) {
+
+            if(diff.type1 == 'missing' && diff.type2 == 'file') {
+                lines.push(await this.makeWriteFile(diff.path2, '.' + diff.relativePath, diff.name2));
+            }
+            else if(diff.type1 == 'file' && diff.type2 == 'missing') {
+                lines.push(await this.makeRemoveFile(diff.path1, '.' + diff.relativePath, diff.name1));
+            }
+            else if(diff.type1 == 'directory' && diff.type2 == 'missing') {
+                lines.push(await this.makeRemoveDir(diff.path1, '.' + diff.relativePath, diff.name1));
+            }
+
+        }
+
+        return lines.join('\n');
+
+    }
+
+    protected async makeRemoveFile(rootDir: string, relativeDir: string, filename: string): Promise<string> {
+        return `Delete "$INSTDIR\\${ win32.normalize(join(relativeDir, filename)) }"`;
+    }
+
+    protected async makeWriteFile(rootDir: string, relativeDir: string, filename: string): Promise<string> {
+        return `SetOutPath "$INSTDIR\\${ win32.normalize(relativeDir) }"
+File "${ win32.normalize(resolve(rootDir, filename)) }"`;
+    }
+
+    protected async makeRemoveDir(rootDir: string, relativeDir: string, filename: string): Promise<string> {
+        return `RMDir /r "$INSTDIR\\${ win32.normalize(join(relativeDir, filename)) }"`;
+    }
+
+}

+ 1 - 0
src/lib/nsis-gen/index.ts

@@ -3,6 +3,7 @@ import { dirname, resolve, win32 } from 'path';
 import { spawn } from 'child_process';
 
 export * from './NsisComposer';
+export * from './NsisDiffer';
 
 const DIR_ASSETS = resolve(dirname(module.filename), '../../../assets/');
 const DIR_NSIS = resolve(DIR_ASSETS, 'nsis');

+ 34 - 19
test/nsis-gen.js

@@ -3,36 +3,51 @@ import { test } from 'ava';
 
 import { writeFileAsync, removeAsync } from 'fs-extra-promise';
 
-import { NsisComposer, nsisBuild } from '../dist/lib/nsis-gen';
+import { NsisComposer, NsisDiffer, nsisBuild } from '../dist/lib/nsis-gen';
 import { tmpName, tmpFile, tmpDir } from '../dist/lib/util';
 
-test('build', async (t) => {
+const options = {
 
-    const output = await tmpName();
+    // Basic.
+    appName: 'Project',
+    companyName: 'evshiron',
+    description: 'description',
+    version: '0.1.0.0',
+    copyright: 'copyright',
 
-    const data = await (new NsisComposer({
+    // Compression.
+    compression: 'lzma',
+    solid: true,
 
-        // Basic.
-        appName: 'Project',
-        companyName: 'evshiron',
-        description: 'description',
-        version: '0.1.0.0',
-        copyright: 'copyright',
+};
 
-        // Compression.
-        compression: 'lzma',
-        solid: true,
+test.skip('build', async (t) => {
 
-        // Styles.
-        xpStyle: true,
+    const output = await tmpName();
 
-        // Files.
+    const data = await (new NsisComposer(Object.assign({}, options, {
         srcDir: './src/',
-
-        // Output.
         output,
+    })))
+    .make();
+
+    const script = await tmpName();
+
+    await writeFileAsync(script, data);
+    await nsisBuild(script);
 
-    }))
+    await removeAsync(output);
+    await removeAsync(script);
+
+});
+
+test('diff', async (t) => {
+
+    const output = await tmpName();
+
+    const data = await (new NsisDiffer('./src/', './dist/', Object.assign({}, options, {
+        output,
+    })))
     .make();
 
     const script = await tmpName();