diff --git a/packages/maker/zip/package.json b/packages/maker/zip/package.json index 214da07f17..d43e20e644 100644 --- a/packages/maker/zip/package.json +++ b/packages/maker/zip/package.json @@ -9,7 +9,8 @@ "typings": "dist/MakerZIP.d.ts", "devDependencies": { "chai": "^4.3.3", - "mocha": "^9.0.1" + "mocha": "^9.0.1", + "sinon": "^13.0.1" }, "engines": { "node": ">= 14.17.5" @@ -18,7 +19,8 @@ "@electron-forge/maker-base": "^6.0.4", "@electron-forge/shared-types": "^6.0.4", "cross-zip": "^4.0.0", - "fs-extra": "^10.0.0" + "fs-extra": "^10.0.0", + "got": "^11.8.5" }, "publishConfig": { "access": "public" diff --git a/packages/maker/zip/src/Config.ts b/packages/maker/zip/src/Config.ts new file mode 100644 index 0000000000..a97d6634c4 --- /dev/null +++ b/packages/maker/zip/src/Config.ts @@ -0,0 +1,23 @@ +export interface MakerZIPConfig { + /** + * A URL to the directory containing your existing macOS auto-update + * RELEASES.json file. If given this maker will download the existing + * file and add this release to it, also setting the "currentRelease" to + * this release. + * + * For instance if your URL is "https://update.example.com/my-app/darwin/x64/RELEASES.json" + * you should provide "https://update.example.com/my-app/darwin/x64". This logic assumes + * that you published your files using a forge publisher compatible with the auto updater (e.g. S3). + * + * Publishing this RELEASES.json will result in clients downloading this version + * as an update. + * + * If this option is not set no RELEASES.json file will be generated. + */ + macUpdateManifestBaseUrl?: string; + /** + * Only used if `squirrelMacManifestBaseUrl` is provided. Used to populate + * the "notes" field of the releases manifest for macOS updates. + */ + macUpdateReleaseNotes?: string; +} diff --git a/packages/maker/zip/src/MakerZIP.ts b/packages/maker/zip/src/MakerZIP.ts index 83d25fcd5f..34e06b59a2 100644 --- a/packages/maker/zip/src/MakerZIP.ts +++ b/packages/maker/zip/src/MakerZIP.ts @@ -1,10 +1,28 @@ import path from 'path'; import { promisify } from 'util'; -import { EmptyConfig, MakerBase, MakerOptions } from '@electron-forge/maker-base'; +import { MakerBase, MakerOptions } from '@electron-forge/maker-base'; import { ForgePlatform } from '@electron-forge/shared-types'; +import fs from 'fs-extra'; +import got from 'got'; -export type MakerZIPConfig = EmptyConfig; +import { MakerZIPConfig } from './Config'; + +type SquirrelMacRelease = { + version: string; + updateTo: { + version: string; + pub_date: string; + notes: string; + name: string; + url: string; + }; +}; + +type SquirrelMacReleases = { + currentRelease: string; + releases: SquirrelMacRelease[]; +}; export default class MakerZIP extends MakerBase { name = 'zip'; @@ -20,13 +38,51 @@ export default class MakerZIP extends MakerBase { const zipDir = ['darwin', 'mas'].includes(targetPlatform) ? path.resolve(dir, `${appName}.app`) : dir; - const zipPath = path.resolve(makeDir, 'zip', targetPlatform, targetArch, `${path.basename(dir)}-${packageJSON.version}.zip`); + const zipName = `${path.basename(dir)}-${packageJSON.version}.zip`; + const zipPath = path.resolve(makeDir, 'zip', targetPlatform, targetArch, zipName); await this.ensureFile(zipPath); await promisify(zip)(zipDir, zipPath); + // Only generate RELEASES.json for darwin builds (not MAS) + if (targetPlatform === 'darwin' && this.config.macUpdateManifestBaseUrl) { + const parsed = new URL(this.config.macUpdateManifestBaseUrl); + parsed.pathname += '/RELEASES.json'; + const response = await got.get(parsed.toString()); + let currentValue: SquirrelMacReleases = { + currentRelease: '', + releases: [], + }; + if (response.statusCode === 200) { + currentValue = JSON.parse(response.body); + } + const updateUrl = new URL(this.config.macUpdateManifestBaseUrl); + updateUrl.pathname += `/${zipName}`; + // Remove existing release if it is already in the manifest + currentValue.releases = currentValue.releases || []; + currentValue.releases = currentValue.releases.filter((release) => release.version !== packageJSON.version); + // Add the current version as the current release + currentValue.currentRelease = packageJSON.version; + currentValue.releases.push({ + version: packageJSON.version, + updateTo: { + name: `${appName} v${packageJSON.version}`, + version: packageJSON.version, + pub_date: new Date().toISOString(), + url: updateUrl.toString(), + notes: this.config.macUpdateReleaseNotes || '', + }, + }); + + const releasesPath = path.resolve(makeDir, 'zip', targetPlatform, targetArch, 'RELEASES.json'); + await this.ensureFile(releasesPath); + await fs.writeJson(releasesPath, currentValue); + + return [zipPath, releasesPath]; + } + return [zipPath]; } } -export { MakerZIP }; +export { MakerZIP, MakerZIPConfig }; diff --git a/packages/maker/zip/test/MakerZip_spec.ts b/packages/maker/zip/test/MakerZip_spec.ts new file mode 100644 index 0000000000..54d4aafe96 --- /dev/null +++ b/packages/maker/zip/test/MakerZip_spec.ts @@ -0,0 +1,198 @@ +import os from 'os'; +import path from 'path'; + +import { ForgeArch } from '@electron-forge/shared-types'; +import { expect } from 'chai'; +import fs from 'fs-extra'; +import got from 'got'; +import { SinonStub, stub } from 'sinon'; + +import { MakerZIPConfig } from '../src/Config'; +import { MakerZIP } from '../src/MakerZIP'; + +describe('MakerZip', () => { + let ensureDirectoryStub: SinonStub; + let config: MakerZIPConfig; + let maker: MakerZIP; + let createMaker: () => void; + + const dir = path.resolve(__dirname, 'fixture', 'fake-app'); + const darwinDir = path.resolve(__dirname, 'fixture', 'fake-darwin-app'); + const makeDir = path.resolve(os.tmpdir(), 'forge-zip-test'); + const appName = 'My Test App'; + const targetArch = process.arch; + const packageJSON = { version: '1.2.3' }; + let getStub: SinonStub; + let isoString: SinonStub; + + beforeEach(() => { + ensureDirectoryStub = stub().returns(Promise.resolve()); + config = {}; + + createMaker = () => { + maker = new MakerZIP(config); + maker.ensureDirectory = ensureDirectoryStub; + maker.prepareConfig(targetArch as ForgeArch); + }; + createMaker(); + getStub = stub(got, 'get'); + isoString = stub(Date.prototype, 'toISOString'); + }); + + afterEach(async () => { + if (await fs.pathExists(makeDir)) { + await fs.remove(makeDir); + } + got.get = getStub.wrappedMethod; + Date.prototype.toISOString = isoString.wrappedMethod; + }); + + for (const platform of ['win32', 'linux']) { + it(`should generate a zip file for a ${platform} app`, async () => { + const output = await maker.make({ + dir, + makeDir, + appName, + targetArch, + targetPlatform: platform, + packageJSON, + forgeConfig: null as any, + }); + + expect(output).to.have.length(1, 'should have made a single file'); + expect(output[0]).to.match(/\.zip$/, 'should be a zip file'); + expect(await fs.pathExists(output[0])).to.equal(true, 'zip file should exist on disk'); + }); + } + + for (const platform of ['darwin', 'mas']) { + it(`should generate a zip file for a ${platform} app`, async () => { + const output = await maker.make({ + dir: darwinDir, + makeDir, + appName, + targetArch, + targetPlatform: platform, + packageJSON, + forgeConfig: null as any, + }); + + expect(output).to.have.length(1, 'should have made a single file'); + expect(output[0]).to.match(/\.zip$/, 'should be a zip file'); + expect(await fs.pathExists(output[0])).to.equal(true, 'zip file should exist on disk'); + }); + } + + describe('macUpdateManifestBaseUrl', () => { + for (const platform of ['win32', 'linux', 'mas']) { + it(`should not result in network calls on ${platform}`, async () => { + const output = await maker.make({ + dir: darwinDir, + makeDir, + appName, + targetArch, + targetPlatform: platform, + packageJSON, + forgeConfig: null as any, + }); + + expect(output).to.have.length(1, 'should have made a single file'); + expect(getStub).to.not.have.been.called; + }); + } + + describe('when making for the darwin platform', () => { + it('should fetch the current RELEASES.json', async () => { + maker.config = { + macUpdateManifestBaseUrl: 'fake://test/foo', + }; + getStub.returns(Promise.resolve({ statusCode: 200, body: '{}' })); + await maker.make({ + dir: darwinDir, + makeDir, + appName, + targetArch, + targetPlatform: 'darwin', + packageJSON, + forgeConfig: null as any, + }); + + expect(getStub).to.have.been.calledOnce; + }); + + it('should generate a valid RELEASES.json manifest', async () => { + maker.config = { + macUpdateManifestBaseUrl: 'fake://test/foo', + }; + getStub.returns(Promise.resolve({ statusCode: 200, body: '{}' })); + const output = await maker.make({ + dir: darwinDir, + makeDir, + appName, + targetArch, + targetPlatform: 'darwin', + packageJSON, + forgeConfig: null as any, + }); + + const foo = await fs.readJson(output[1]); + expect(foo).to.have.property('currentRelease', '1.2.3'); + expect(foo).to.have.property('releases'); + expect(foo.releases).to.be.an('array').with.lengthOf(1); + expect(foo.releases[0]).to.have.property('version'); + expect(foo.releases[0]).to.have.property('updateTo'); + expect(foo.releases[0].updateTo).to.have.property('url'); + }); + + it('should extend the current RELEASES.json manifest if it exists', async () => { + maker.config = { + macUpdateManifestBaseUrl: 'fake://test/foo', + macUpdateReleaseNotes: 'my-notes', + }; + const oneOneOneRelease = { + version: '1.1.1', + updateTo: { + version: '1.1.1', + name: 'Fun 1.1.1 Release', + url: 'fake://test/bar', + }, + }; + getStub.returns( + Promise.resolve({ + statusCode: 200, + body: JSON.stringify({ + currentRelease: '1.1.1', + releases: [oneOneOneRelease], + }), + }) + ); + isoString.returns('fake-date'); + const output = await maker.make({ + dir: darwinDir, + makeDir, + appName, + targetArch, + targetPlatform: 'darwin', + packageJSON, + forgeConfig: null as any, + }); + + const foo = await fs.readJson(output[1]); + expect(foo).to.have.property('currentRelease', '1.2.3'); + expect(foo).to.have.property('releases'); + expect(foo.releases).to.be.an('array').with.lengthOf(2); + expect(foo.releases[0]).to.deep.equal(oneOneOneRelease); + expect(foo.releases[1]).to.deep.equal({ + version: '1.2.3', + updateTo: { + version: '1.2.3', + name: 'My Test App v1.2.3', + url: 'fake://test/foo/fake-darwin-app-1.2.3.zip', + notes: 'my-notes', + pub_date: 'fake-date', + }, + }); + }); + }); + }); +}); diff --git a/packages/maker/zip/test/fixture/fake-app/index.js b/packages/maker/zip/test/fixture/fake-app/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/maker/zip/test/fixture/fake-darwin-app/My Test App.app/index.js b/packages/maker/zip/test/fixture/fake-darwin-app/My Test App.app/index.js new file mode 100644 index 0000000000..e69de29bb2