diff --git a/src/core/core.js b/src/core/core.js index 79724ecb..64dc7036 100644 --- a/src/core/core.js +++ b/src/core/core.js @@ -150,6 +150,7 @@ class Core { } readConfigFile(file) { + log.verbose("Reading config file: " + file); return file ? Promise.resolve( path.isAbsolute(file) ? file : path.join(process.cwd(), file) diff --git a/src/core/plugins/index.js b/src/core/plugins/index.js index 1fb61302..f512db06 100644 --- a/src/core/plugins/index.js +++ b/src/core/plugins/index.js @@ -22,6 +22,7 @@ const { CancelablePromise } = require("cancelable-promise"); const AdbPlugin = require("./adb/plugin.js"); const AsteroidOsPlugin = require("./asteroid_os/plugin.js"); const LineageOSPlugin = require("./lineage_os/plugin.js"); +const PostmarketOSPlugin = require("./postmarketos/plugin.js"); const CorePlugin = require("./core/plugin.js"); const FastbootPlugin = require("./fastboot/plugin.js"); const HeimdallPlugin = require("./heimdall/plugin.js"); @@ -34,6 +35,7 @@ const SystemimagePlugin = require("./systemimage/plugin.js"); * @property {AdbPlugin} plugins.adb adb plugin * @property {AsteroidOsPlugin} plugins.asteroid_os AteroidOS plugin * @property {LineageOSPlugin} plugins.lineage_os LineageOS plugin + * @property {PostmarketOSPlugin} plugins.postmarketos postmarketOS plugin * @property {CorePlugin} plugins.core core plugin * @property {FastbootPlugin} plugins.fastboot fastboot plugin * @property {HeimdallPlugin} plugins.heimdall heimdall plugin @@ -50,6 +52,7 @@ class PluginIndex { adb: new AdbPlugin(...pluginArgs), asteroid_os: new AsteroidOsPlugin(...pluginArgs), lineage_os: new LineageOSPlugin(...pluginArgs), + postmarketos: new PostmarketOSPlugin(...pluginArgs), core: new CorePlugin(...pluginArgs), fastboot: new FastbootPlugin(...pluginArgs), heimdall: new HeimdallPlugin(...pluginArgs), diff --git a/src/core/plugins/index.spec.js b/src/core/plugins/index.spec.js index e665ff6f..922a0b57 100644 --- a/src/core/plugins/index.spec.js +++ b/src/core/plugins/index.spec.js @@ -101,7 +101,7 @@ describe("PluginIndex", () => { }); describe("getPluginMappable()", () => { it("should return plugin array", () => - expect(pluginIndex.getPluginMappable()).toHaveLength(7)); + expect(pluginIndex.getPluginMappable()).toHaveLength(8)); }); ["init", "kill"].forEach(target => describe(`${target}()`, () => { diff --git a/src/core/plugins/postmarketos/api.js b/src/core/plugins/postmarketos/api.js new file mode 100644 index 00000000..4a3bc156 --- /dev/null +++ b/src/core/plugins/postmarketos/api.js @@ -0,0 +1,107 @@ +"use strict"; + +/* + * Copyright (C) 2020-2021 UBports Foundation + * Copyright (c) 2022 Caleb Connolly + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const axios = require("axios"); + +/** @module postmarketOS */ + +const baseURL = "https://images.postmarketos.org"; + +const api = axios.create({ baseURL, timeout: 15000 }); + +/** + * get interfaces from api + * @param {String} device device codename + * @returns {Promise>} interfaces + * @throws {Error} message "unsupported" if 404 not found + */ +const getInterfaces = device => + api + .get("/bpo/index.json") + .then(({ data }) => { + const devices = data.releases.find(c => c.name === "edge").devices; + const interfaces = devices + .find(d => d.name.includes(device)) + .interfaces.map(i => i.name); + return interfaces.map(i => { + if (i === "phosh") return { value: i, label: "Phosh" }; + if (i === "plasma-mobile") return { value: i, label: "Plasma Mobile" }; + if (i === "sxmo-de-sway") return { value: i, label: "SXMO Sway" }; + return { value: i, label: i }; + }); + }) + .catch(error => { + if (error?.response?.status === 404) throw new Error("404"); + throw error; + }); + +/** + * get images from api + * @param {String} release release + * @param {String} ui user interface + * @param {String} device device codename + * @returns {Promise>} images array + * @throws {Error} message "no network" if request failed + */ +const getImages = (release, ui, device) => + api + .get("/bpo/index.json") + .then(({ data }) => { + const rel = data.releases.find(c => c.name === release); + const dev = rel.devices.find(d => d.name.includes(device)); + const images = dev.interfaces.find(i => i.name === ui).images; + // The first two are the latest rootfs and boot image + const ts_latest = images[0].timestamp; + return images + .filter(i => i.timestamp === ts_latest) + .map(i => ({ + url: i.url, + checksum: { + sum: i.sha256, + algorithm: "sha256" + } + })); + }) + .catch(error => { + if (error?.response?.status === 404) throw new Error("404"); + throw error; + }); + +/** + * get releases from api + * @param {String} device device codename + * @returns {Promise>} releases + * @throws {Error} message "unsupported" if 404 not found + */ +const getReleases = device => + api + .get("/bpo/index.json") + .then(({ data }) => { + const releases = data.releases; + return releases + .filter(release => release.devices.find(d => d.name.includes(device))) + .map(release => release.name); + }) + .catch(error => { + if (error?.response?.status === 404) throw new Error("404"); + throw error; + }); + +module.exports = { getInterfaces, getImages, getReleases }; diff --git a/src/core/plugins/postmarketos/api.spec.js b/src/core/plugins/postmarketos/api.spec.js new file mode 100644 index 00000000..70b1ec0d --- /dev/null +++ b/src/core/plugins/postmarketos/api.spec.js @@ -0,0 +1,132 @@ +const axios = require("axios"); +jest.mock("axios"); +axios.create.mockReturnValue(axios); +const api = require("./api.js"); + +const MOCK_DATA = { + releases: [ + { + name: "edge", + devices: [ + { + name: "somedevice", + interfaces: [ + { name: "phosh" }, + { + name: "plasma-mobile", + images: [ + { + timestamp: 0, + url: "someurl", + sha256: "sha256-first" + }, + { + timestamp: 0, + url: "someurl2", + sha256: "sha256-other" + } + ] + }, + { name: "sxmo-de-sway" }, + { name: "other" } + ] + } + ] + } + ] +}; + +describe("postmarketos api", () => { + beforeEach(() => { + axios.get.mockResolvedValueOnce({ + data: MOCK_DATA + }); + }); + + describe("getInterfaces()", () => { + it("should resolve interfaces", async () => { + const result = await api.getInterfaces("somedevice"); + expect(result).toContainEqual({ + value: "phosh", + label: "Phosh" + }); + expect(result).toContainEqual({ + value: "plasma-mobile", + label: "Plasma Mobile" + }); + expect(result).toContainEqual({ + value: "sxmo-de-sway", + label: "SXMO Sway" + }); + expect(result).toContainEqual({ + value: "other", + label: "other" + }); + }); + + it("should reject on errors", async () => { + axios.get.mockReset(); + axios.get.mockRejectedValueOnce({ + response: { + status: 404 + } + }); + + const test = () => api.getInterfaces("nonexistent"); + await expect(test).rejects.toThrow("404"); + + axios.get.mockRejectedValueOnce(new Error("other")); + await expect(test).rejects.toThrow("other"); + }); + }); + + describe("getImages()", () => { + it("should resolve images", async () => { + const result = await api.getImages("edge", "plasma-mobile", "somedevice"); + expect(result).toContainEqual({ + url: "someurl", + checksum: { + sum: "sha256-first", + algorithm: "sha256" + } + }); + }); + + it("should throw on 404", async () => { + axios.get.mockReset(); + axios.get.mockRejectedValueOnce({ + response: { + status: 404 + } + }); + + const test = () => api.getImages("non", "existent", "stuff"); + await expect(test).rejects.toThrow("404"); + + axios.get.mockRejectedValueOnce(new Error("other")); + await expect(test).rejects.toThrow("other"); + }); + }); + + describe("getReleases()", () => { + it("should resolve releases", async () => { + const result = await api.getReleases("somedevice"); + expect(result).toEqual(["edge"]); + }); + + it("should throw on 404", async () => { + axios.get.mockReset(); + axios.get.mockRejectedValueOnce({ + response: { + status: 404 + } + }); + + const test = () => api.getReleases("nonexistent"); + await expect(test).rejects.toThrow("404"); + + axios.get.mockRejectedValueOnce(new Error("other")); + await expect(test).rejects.toThrow("other"); + }); + }); +}); diff --git a/src/core/plugins/postmarketos/plugin.js b/src/core/plugins/postmarketos/plugin.js new file mode 100644 index 00000000..1d3ea639 --- /dev/null +++ b/src/core/plugins/postmarketos/plugin.js @@ -0,0 +1,126 @@ +"use strict"; + +/* + * Copyright (C) 2022 Alexander Martinz + * Copyright (C) 2022 Caleb Connolly + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const Plugin = require("../plugin.js"); +const api = require("./api.js"); +const fs = require("fs/promises"); +const path = require("path"); + +/** + * postmarketOS plugin + * @extends Plugin + */ +class PostmarketOSPlugin extends Plugin { + /** + * action download + * @returns {Promise>} + */ + action__download() { + return api + .getImages( + this.props.settings["release"], + this.props.settings["interface"], + this.props.config.codename + ) + .then(files => [ + { + actions: [ + { + "core:download": { + group: "postmarketOS", + files + } + }, + { + "core:unpack": { + group: "postmarketOS", + files: files.map(file => ({ + ...file, + archive: file.name || path.basename(file.url) + })) + } + }, + { + "postmarketos:rename_unpacked_files": { + group: "postmarketOS", + files + } + } + ] + } + ]); + } + + /** + * action rename_unpacked_files + * @returns {Promise} + */ + action__rename_unpacked_files({ group, files }) { + return Promise.resolve().then(async () => { + this.event.emit("user:write:working", "squares"); + this.event.emit("user:write:status", "Preparing files", true); + this.event.emit("user:write:under", "Preparing files"); + const basepath = path.join( + this.cachePath, + this.props.config.codename, + group + ); + files = files.map(file => ({ + ...file, + path: path.join(basepath, file.name || path.basename(file.url)) + })); + + // Detect which image is which type, see: https://gitlab.com/postmarketOS/build.postmarketos.org/-/issues/113 + const rootfs_path = files + .find(file => !file.path.endsWith("boot.img.xz")) + .path.replace(/.xz$/, ""); + const boot_path = files + .find(file => file.path.endsWith("boot.img.xz")) + .path.replace(/.xz$/, ""); + await Promise.all([ + fs.rename(rootfs_path, path.join(basepath, "rootfs.img")), + fs.rename(boot_path, path.join(basepath, "boot.img")) + ]); + }); + } + + /** + * interfaces remote_values + * @returns {Promise>} + */ + remote_values__interfaces() { + return api.getInterfaces(this.props.config.codename); + } + + /** + * releases remote_values + * @returns {Promise>} + */ + remote_values__releases() { + return api.getReleases(this.props.config.codename).then(releases => + releases.map(release => ({ + value: release, + label: release + })) + ); + } +} + +module.exports = PostmarketOSPlugin; diff --git a/src/core/plugins/postmarketos/plugin.spec.js b/src/core/plugins/postmarketos/plugin.spec.js new file mode 100644 index 00000000..ecf76b02 --- /dev/null +++ b/src/core/plugins/postmarketos/plugin.spec.js @@ -0,0 +1,113 @@ +const mainEvent = { emit: jest.fn() }; +beforeEach(() => mainEvent.emit.mockReset()); +const fs = require("fs/promises"); +jest.mock("fs/promises", () => ({ + rename: jest.fn() +})); +const log = require("../../../lib/log.js"); +jest.mock("../../../lib/log.js"); +const api = require("./api.js"); +jest.mock("./api.js"); +const path = require("path"); + +const cachePath = "surprise.xz/inthename"; + +const pmosPlugin = new (require("./plugin.js"))( + { + settings: { + release: "somerelease", + interface: "someinterface" + }, + config: { + codename: "somecodename" + } + }, + cachePath, + mainEvent, + log +); + +describe("postmarketos plugin", () => { + describe("action__download()", () => { + it("should download images", async () => { + const files = [{ url: "http://somewebsite.com/somefilename.zip" }]; + api.getImages.mockResolvedValueOnce(files); + + const ret = await pmosPlugin.action__download(); + expect(api.getImages).toHaveBeenCalledWith( + "somerelease", + "someinterface", + "somecodename" + ); + expect(ret[0]).toBeDefined(); + expect(ret[0].actions).toContainEqual({ + "core:download": { + group: "postmarketOS", + files + } + }); + expect(ret[0].actions).toContainEqual({ + "postmarketos:rename_unpacked_files": { + group: "postmarketOS", + files + } + }); + expect(ret[0].actions[1]["core:unpack"].files).toContainEqual({ + url: files[0].url, + archive: "somefilename.zip" + }); + }); + }); + + describe("action__rename_unpacked_files()", () => { + it("should rename the files", async () => { + jest.spyOn(pmosPlugin.event, "emit").mockReturnValue(); + + const group = "group"; + const basepath = path.join(cachePath, "somecodename", group); + const files = [ + { + url: "https://asdf.io/somethingelse.img.xz" + }, + { + url: "https://asdf.io/somethingelse-boot.img.xz" + } + ]; + + await pmosPlugin.action__rename_unpacked_files({ group, files }); + expect(pmosPlugin.event.emit).toHaveBeenCalledTimes(3); + expect(fs.rename).toHaveBeenCalledWith( + path.join(basepath, "somethingelse.img"), + path.join(basepath, "rootfs.img") + ); + expect(fs.rename).toHaveBeenCalledWith( + path.join(basepath, "somethingelse-boot.img"), + path.join(basepath, "boot.img") + ); + }); + }); + + describe("remote_values__interfaces()", () => { + it("should get interfaces", async () => { + await pmosPlugin.remote_values__interfaces(); + + expect(api.getInterfaces).toHaveBeenCalledWith("somecodename"); + }); + }); + + describe("remote_values__releases()", () => { + it("should get releases", async () => { + api.getReleases.mockResolvedValueOnce(["a", "b"]); + const result = await pmosPlugin.remote_values__releases(); + expect(api.getReleases).toHaveBeenCalledWith("somecodename"); + expect(result).toContainEqual({ + label: "a", + value: "a" + }); + expect(result).toContainEqual({ + label: "b", + value: "b" + }); + }); + }); +});