diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index f4ebbb2f6..af90e2035 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -280,7 +280,8 @@ export class ArduinoFrontendContribution } this.updaterService.init( - this.arduinoPreferences.get('arduino.ide.updateChannel') + this.arduinoPreferences.get('arduino.ide.updateChannel'), + this.arduinoPreferences.get('arduino.ide.updateBaseUrl') ); this.updater.checkForUpdates(true).then(async (updateInfo) => { if (!updateInfo) return; diff --git a/arduino-ide-extension/src/browser/arduino-preferences.ts b/arduino-ide-extension/src/browser/arduino-preferences.ts index 910d724bc..ba6bc83c4 100644 --- a/arduino-ide-extension/src/browser/arduino-preferences.ts +++ b/arduino-ide-extension/src/browser/arduino-preferences.ts @@ -78,6 +78,14 @@ export const ArduinoConfigSchema: PreferenceSchema = { "Release channel to get updated from. 'stable' is the stable release, 'nightly' is the latest development build." ), }, + 'arduino.ide.updateBaseUrl': { + type: 'string', + default: 'https://downloads.arduino.cc/arduino-ide', + description: nls.localize( + 'arduino/preferences/ide.updateBaseUrl', + `The base URL where to download updates from. Defaults to 'https://downloads.arduino.cc/arduino-ide'` + ), + }, 'arduino.board.certificates': { type: 'string', description: nls.localize( @@ -178,6 +186,7 @@ export interface ArduinoConfiguration { 'arduino.window.autoScale': boolean; 'arduino.window.zoomLevel': number; 'arduino.ide.updateChannel': UpdateChannel; + 'arduino.ide.updateBaseUrl': string; 'arduino.board.certificates': string; 'arduino.sketchbook.showAllFiles': boolean; 'arduino.cloud.enabled': boolean; diff --git a/arduino-ide-extension/src/common/protocol/ide-updater.ts b/arduino-ide-extension/src/common/protocol/ide-updater.ts index f7fdc96ac..92226398c 100644 --- a/arduino-ide-extension/src/common/protocol/ide-updater.ts +++ b/arduino-ide-extension/src/common/protocol/ide-updater.ts @@ -46,7 +46,7 @@ export interface ProgressInfo { export const IDEUpdaterPath = '/services/ide-updater'; export const IDEUpdater = Symbol('IDEUpdater'); export interface IDEUpdater extends JsonRpcServer { - init(channel: UpdateChannel): void; + init(channel: UpdateChannel, baseUrl: string): void; checkForUpdates(initialCheck?: boolean): Promise; downloadUpdate(): Promise; quitAndInstall(): void; diff --git a/arduino-ide-extension/src/electron-main/ide-updater/ide-updater-impl.ts b/arduino-ide-extension/src/electron-main/ide-updater/ide-updater-impl.ts index 06e840999..b953c3281 100644 --- a/arduino-ide-extension/src/electron-main/ide-updater/ide-updater-impl.ts +++ b/arduino-ide-extension/src/electron-main/ide-updater/ide-updater-impl.ts @@ -8,7 +8,6 @@ import { } from '../../common/protocol/ide-updater'; const CHANGELOG_BASE_URL = 'https://downloads.arduino.cc/arduino-ide/changelog'; -const IDE_DOWNLOAD_BASE_URL = 'https://downloads.arduino.cc/arduino-ide'; @injectable() export class IDEUpdaterImpl implements IDEUpdater { @@ -18,14 +17,12 @@ export class IDEUpdaterImpl implements IDEUpdater { protected theiaFEClient?: IDEUpdaterClient; protected clients: Array = []; - init(channel: UpdateChannel): void { + init(channel: UpdateChannel, baseUrl: string): void { this.updater.autoDownload = false; this.updater.channel = channel; this.updater.setFeedURL({ provider: 'generic', - url: `${IDE_DOWNLOAD_BASE_URL}/${ - channel === UpdateChannel.Nightly ? 'nightly' : '' - }`, + url: `${baseUrl}/${channel === UpdateChannel.Nightly ? 'nightly' : ''}`, channel, }); diff --git a/electron/packager/index.js b/electron/packager/index.js index 776b2dc3e..8557add35 100644 --- a/electron/packager/index.js +++ b/electron/packager/index.js @@ -392,7 +392,11 @@ ${fs.readFileSync(path('..', 'build', 'package.json')).toString()} for (const fileToCopy of filesToCopy) { echo(`🚢 >>> Copying ${fileToCopy} to ${targetFolder}.`); const isZip = await utils.isZip(fileToCopy); - cp('-rf', fileToCopy, targetFolder); + if (isZip && platform === 'linux') { + await utils.adjustArchiveStructure(fileToCopy, targetFolder); + } else { + cp('-rf', fileToCopy, targetFolder); + } echo(`👌 >>> Copied ${fileToCopy} to ${targetFolder}.`); } } diff --git a/electron/packager/package.json b/electron/packager/package.json index 4841808e9..e1f6c72df 100644 --- a/electron/packager/package.json +++ b/electron/packager/package.json @@ -7,7 +7,7 @@ "scripts": { "prepare": "yarn test", "package": "node index.js", - "test": "echo 'No test implemented'" + "test": "mocha \"./test/**/*.test.js\"" }, "keywords": [], "author": "Arduino SA", diff --git a/electron/packager/test/resources/not-a-zip.dmg b/electron/packager/test/resources/not-a-zip.dmg new file mode 100644 index 000000000..a0b7a61d1 Binary files /dev/null and b/electron/packager/test/resources/not-a-zip.dmg differ diff --git a/electron/packager/test/resources/zip with whitespace.zip b/electron/packager/test/resources/zip with whitespace.zip new file mode 100644 index 000000000..24f7680a8 Binary files /dev/null and b/electron/packager/test/resources/zip with whitespace.zip differ diff --git a/electron/packager/test/resources/zip-with-base-folder.zip b/electron/packager/test/resources/zip-with-base-folder.zip new file mode 100644 index 000000000..b5f71f919 Binary files /dev/null and b/electron/packager/test/resources/zip-with-base-folder.zip differ diff --git a/electron/packager/test/resources/zip-with-symlink.zip b/electron/packager/test/resources/zip-with-symlink.zip new file mode 100644 index 000000000..259dba4a9 Binary files /dev/null and b/electron/packager/test/resources/zip-with-symlink.zip differ diff --git a/electron/packager/test/resources/zip-without-symlink.zip b/electron/packager/test/resources/zip-without-symlink.zip new file mode 100644 index 000000000..24f7680a8 Binary files /dev/null and b/electron/packager/test/resources/zip-without-symlink.zip differ diff --git a/electron/packager/test/utils.test.js b/electron/packager/test/utils.test.js new file mode 100644 index 000000000..0e5c5d558 --- /dev/null +++ b/electron/packager/test/utils.test.js @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); +const expect = require('chai').expect; +const track = require('temp').track(); +const unpack = require('../utils').unpack; +const testMe = require('../utils'); +const sinon = require('sinon'); + +describe('utils', () => { + + describe('adjustArchiveStructure', () => { + + let consoleStub; + + beforeEach(() => { + consoleStub = sinon.stub(console, 'log').value(() => { }); + }); + + afterEach(() => { + consoleStub.reset(); + track.cleanupSync(); + }); + + it('should reject when not a zip file', async () => { + try { + const invalid = path.join(__dirname, 'resources', 'not-a-zip.dmg'); + await testMe.adjustArchiveStructure(invalid, track.mkdirSync()); + throw new Error('Expected a rejection'); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + expect(e.message).to.be.equal('Expected a ZIP file.'); + } + }); + + it('should reject when target directory does not exist', async () => { + try { + const zip = path.join(__dirname, 'resources', 'zip-with-base-folder.zip'); + await testMe.adjustArchiveStructure(zip, path.join(__dirname, 'some', 'missing', 'path')); + throw new Error('Expected a rejection'); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + expect(e.message.endsWith('does not exist.')).to.be.true; + } + }); + + it('should reject when target is a file', async () => { + try { + const zip = path.join(__dirname, 'resources', 'zip-with-base-folder.zip'); + await testMe.adjustArchiveStructure(zip, path.join(__filename)); + throw new Error('Expected a rejection'); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + expect(e.message.endsWith('is not a directory.')).to.be.true; + } + }); + + it('should be a NOOP when the zip already has the desired base folder', async () => { + const zip = path.join(__dirname, 'resources', 'zip-with-base-folder.zip'); + const actual = await testMe.adjustArchiveStructure(zip, track.mkdirSync()); + expect(actual).to.be.equal(zip); + }); + + it('should handle whitespace in file path gracefully', async () => { + const zip = path.join(__dirname, 'resources', 'zip with whitespace.zip'); + const out = track.mkdirSync(); + const actual = await testMe.adjustArchiveStructure(zip, out, true); + expect(actual).to.be.equal(path.join(out, 'zip with whitespace.zip')); + console.log(actual); + expect(fs.existsSync(actual)).to.be.true; + + const verifyOut = track.mkdirSync(); + await unpack(actual, verifyOut); + + const root = path.join(verifyOut, 'zip with whitespace'); + expect(fs.existsSync(root)).to.be.true; + expect(fs.lstatSync(root).isDirectory()).to.be.true; + const subs = fs.readdirSync(root); + expect(subs).to.have.lengthOf(3); + expect(subs.sort()).to.be.deep.equal(['a.txt', 'b.txt', 'foo']); + }); + + it('should keep the symlinks after ZIP adjustments', async function () { + if (process.platform === 'win32') { + this.skip(); + } + const zip = path.join(__dirname, 'resources', 'zip-with-symlink.zip'); + const out = track.mkdirSync(); + const actual = await testMe.adjustArchiveStructure(zip, out, true); + expect(actual).to.be.equal(path.join(out, 'zip-with-symlink.zip')); + console.log(actual); + expect(fs.existsSync(actual)).to.be.true; + + const verifyOut = track.mkdirSync(); + await unpack(actual, verifyOut); + expect(fs.lstatSync(path.join(verifyOut, 'zip-with-symlink', 'folder', 'symlinked-sub')).isSymbolicLink()).to.be.true; + }); + + it('should adjust the archive structure if base folder is not present', async () => { + const zip = path.join(__dirname, 'resources', 'zip-without-symlink.zip'); + const out = track.mkdirSync(); + const actual = await testMe.adjustArchiveStructure(zip, out, true); + expect(actual).to.be.equal(path.join(out, 'zip-without-symlink.zip')); + console.log(actual); + expect(fs.existsSync(actual)).to.be.true; + + const verifyOut = track.mkdirSync(); + await unpack(actual, verifyOut); + + const root = path.join(verifyOut, 'zip-without-symlink'); + expect(fs.existsSync(root)).to.be.true; + expect(fs.lstatSync(root).isDirectory()).to.be.true; + const subs = fs.readdirSync(root); + expect(subs).to.have.lengthOf(3); + expect(subs.sort()).to.be.deep.equal(['a.txt', 'b.txt', 'foo']); + }); + + }); + +}); \ No newline at end of file diff --git a/electron/packager/utils.js b/electron/packager/utils.js index 2f9d37eae..01be7f92e 100644 --- a/electron/packager/utils.js +++ b/electron/packager/utils.js @@ -50,6 +50,67 @@ function collectUnusedDependencies(pathToProject = process.cwd()) { }); } +/** + * `pathToZip` is a `path/to/your/app-name.zip`. + * If the `pathToZip` archive does not have a root directory with name `app-name`, it creates one, and move the content from the + * archive's root to the new root folder. If the archive already has the desired root folder, calling this function is a NOOP. + * If `pathToZip` is not a ZIP, rejects. `targetFolderName` is the destination folder not the new archive location. + */ +function adjustArchiveStructure(pathToZip, targetFolderName, noCleanup) { + return new Promise(async (resolve, reject) => { + if (!(await isZip(pathToZip))) { + reject(new Error(`Expected a ZIP file.`)); + return; + } + if (!fs.existsSync(targetFolderName)) { + reject(new Error(`${targetFolderName} does not exist.`)); + return; + } + if (!fs.lstatSync(targetFolderName).isDirectory()) { + reject(new Error(`${targetFolderName} is not a directory.`)); + return; + } + console.log(`⏱️ >>> Adjusting ZIP structure ${pathToZip}...`); + + const root = basename(pathToZip); + const resources = await list(pathToZip); + const hasBaseFolder = resources.find((name) => name === root); + if (hasBaseFolder) { + if ( + resources.filter((name) => name.indexOf(path.sep) === -1).length > 1 + ) { + console.warn( + `${pathToZip} ZIP has the desired root folder ${root}, however the ZIP contains other entries too: ${JSON.stringify( + resources + )}` + ); + } + console.log(`👌 <<< The ZIP already has the desired ${root} folder.`); + resolve(pathToZip); + return; + } + + const track = temp.track(); + try { + const unzipOut = path.join(track.mkdirSync(), root); + fs.mkdirSync(unzipOut); + await unpack(pathToZip, unzipOut); + const adjustedZip = path.join(targetFolderName, path.basename(pathToZip)); + await pack(unzipOut, adjustedZip); + console.log( + `👌 <<< Adjusted the ZIP structure. Moved the modified ${basename( + pathToZip + )} to the ${targetFolderName} folder.` + ); + resolve(adjustedZip); + } finally { + if (!noCleanup) { + track.cleanupSync(); + } + } + }); +} + /** * Returns the `basename` of `pathToFile` without the file extension. */ @@ -152,6 +213,7 @@ function getChannelFile(platform) { module.exports = { collectUnusedDependencies, + adjustArchiveStructure, isZip, unpack, isNightly,