Files
packager/packager.js
T
2021-05-30 12:23:48 -05:00

489 lines
15 KiB
JavaScript

/*
Copyright (c) 2020 Thomas Weber
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 <https://www.gnu.org/licenses/>.
*/
window.Packager = (function() {
// @ts-ignore
const JSZip = window.JSZip;
// @ts-ignore
const Icns = window.Icns;
const readAsDataURL = (blob) => new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(new Error('could not read as data url'));
fr.readAsDataURL(blob);
});
const readAsArrayBuffer = (blob) => new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(new Error('could not read as arraybuffer'));
fr.readAsArrayBuffer(blob);
});
const loadImage = (src) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Could not load image: ${src}`));
image.src = src;
});
const canvasToBlob = (canvas) => new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Could not read <canvas> as blob'));
}
}); // png is default type, quality is ignored
});
const isObject = (obj) => typeof obj === 'object' && obj !== null;
const assetManifest = {
'turbowarp': {
src: 'https://packagerdata.turbowarp.org/packager.e212db007a686ee5a5c1.js',
size: 3040000 // TODO get accurate number, it's complicated because of gzip
},
'nwjs-win64': {
src: 'https://packagerdata.turbowarp.org/nwjs-v0.49.0-win-x64.zip',
size: 97406745
},
'nwjs-mac': {
src: 'https://packagerdata.turbowarp.org/nwjs-v0.49.0-osx-x64.zip',
size: 128684835
}
};
const LOAD_ASSETS_FROM_LOCALHOST = false;
const fetchManifestAsset = (assetName, progressCallback) => {
const manifestEntry = assetManifest[assetName];
if (manifestEntry._data) {
return Promise.resolve(manifestEntry._data);
}
return new Promise((resolve, reject) => {
let {src, size} = manifestEntry;
if (LOAD_ASSETS_FROM_LOCALHOST) {
const srcURL = new URL(src);
src = srcURL.pathname;
}
const xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status === 200) {
manifestEntry._data = xhr.response;
resolve(xhr.response);
} else {
reject(new Error(`Unexpected status code ${xhr.status} while downloading ${src}`));
}
};
xhr.onerror = () => reject(new Error('XHR failed'));
xhr.onprogress = (e) => {
progressCallback(e.loaded / size);
};
xhr.onloadend = () => progressCallback(1);
xhr.open('GET', src);
xhr.send();
});
};
const fetch = (url) => window.fetch(url)
.then((response) => {
if (response.status !== 200) {
throw new Error(`Unexpected status code ${response.status} while fetching ${url}`);
}
return response;
});
/**
* @typedef {object} ProjectReference
* @property {string} data
* @property {string} type
*/
class Project {
constructor(blob, type) {
this.blob = blob;
this.type = type;
}
/**
* @returns {Promise<ProjectReference>}
*/
async asDataURL() {
return {
data: await readAsDataURL(this.blob),
type: this.type
};
}
/**
* @returns {Promise<ProjectReference>}
*/
async asFetchedFrom(path) {
// Environment is responsible for making sure the file will be available at the given path.
return {
data: path,
type: this.type
};
}
}
class Runtime {
setProgressTarget(progressTarget) {
/** @type {EventTarget} */
this.progressTarget = progressTarget;
}
/**
* @param {ProjectReference} projectReference
*/
package(projectReference) {
throw new Error('Not implemented');
}
}
class Environment {
setRuntime(runtime) {
/** @type {Runtime} */
this.runtime = runtime;
}
setProgressTarget(progressTarget) {
if (!this.runtime) {
throw new Error('setRuntime should be run before setProgressTarget');
}
/** @type {EventTarget} */
this.progressTarget = progressTarget;
this.runtime.setProgressTarget(progressTarget);
}
setProjectData(projectData) {
/** @type {Project} */
this.projectData = projectData;
}
package() {
throw new Error('Not implemented');
}
}
class TurboWarp extends Runtime {
constructor(options) {
super();
this.options = options;
}
async getJS () {
const res = await fetchManifestAsset('turbowarp', (progress) => {
this.progressTarget.dispatchEvent(new CustomEvent('turbowarp-progress', {
detail: progress
}));
});
const src = await res.text();
return src;
}
async package(projectReference) {
const stringify = (v) => {
if (typeof v === 'undefined') {
throw new Error('Undefined value');
}
return JSON.stringify(v);
};
const script = await this.getJS();
return `<!DOCTYPE html>
<html>
<!--
Generated by https://packager.turbowarp.org/
-->
<head>
<meta charset="utf-8">
<!-- TODO: determine CSP -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
#splash {
user-select: none;
background-color: hsla(0, 100%, 65%, 1); color: white; font-family: sans-serif;
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center; text-align: center;
}
#splash h1 { font-size: 2rem; font-weight: bold; }
.tw-loaded #splash { display: none; }
body, [class^="gui_fullscreen-background"] {
background-color: ${this.options.backgroundColor} !important;
}
</style>
</head>
<body>
<div id="splash" aria-hidden="true">
<div>
<h1>Loading scripts</h1>
<p>This may take a while …</p>
</div>
</div>
<script>
var vm = null;
window.__PACKAGER__ = {
projectData: ${stringify(projectReference.data)},
id: ${stringify(this.options.projectId)},
username: ${stringify(this.options.username)},
cloudHost: ${stringify(this.options.cloudHost)},
handleVmInit: function (_vm) {
vm = _vm;
// Custom JS:
${this.options.customJS}
},
handleProjectLoaded: function () {
vm.setCompilerOptions(${stringify(this.options.compilerOptions)});
vm.setFramerate(${stringify(this.options.framerate)});
const runtimeOptions = {};
if (${stringify(this.options.infiniteClones)}) runtimeOptions.maxClones = Infinity;
if (${stringify(this.options.removeFencing)}) runtimeOptions.fencing = false;
if (${stringify(this.options.removeMiscLimits)}) runtimeOptions.miscLimits = false;
vm.setRuntimeOptions(runtimeOptions);
if (${stringify(this.options.highQualityPen)}) vm.renderer.setUseHighQualityRender(true);
if (${stringify(this.options.turbo)}) vm.setTurboMode(true);
if (${stringify(this.options.interpolation)}) vm.setInterpolation(true);
if (${stringify(this.options.autoplay)}) { vm.start(); vm.greenFlag(); }
},
stageWidth: ${stringify(this.options.stageSize.width)},
stageHeight: ${stringify(this.options.stageSize.height)},
noControls: ${stringify(this.options.noControls)},
};
</script>
<script>
${script.replace(/<\/script>/g,"</scri'+'pt>")}
</script>
</body>
</html>`;
}
}
class HTML extends Environment {
async package() {
const packagerData = await this.runtime.package(await this.projectData.asDataURL());
return {
data: packagerData,
filename: 'project.html',
};
}
}
class Zip extends Environment {
async package() {
const zip = new JSZip();
const packagerData = await this.runtime.package(await this.projectData.asFetchedFrom('project.zip'));
zip.file('index.html', packagerData);
zip.file('project.zip', this.projectData.blob);
return {
data: await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE'
}),
filename: 'project.zip',
};
}
}
/**
* Convert a PNG to an Apple Icon Image (ICNS) file
* @param {Blob} pngData PNG image data.
*/
const pngToAppleICNS = async (pngData) => {
const FORMATS = [
{ type: 'ic04', size: 16 },
{ type: 'ic07', size: 128 },
{ type: 'ic08', size: 256 },
{ type: 'ic09', size: 512 },
{ type: 'ic10', size: 1024 },
{ type: 'ic11', size: 32 },
{ type: 'ic12', size: 64 },
{ type: 'ic13', size: 256 },
{ type: 'ic14', size: 512 },
];
// Read the Image.
const url = URL.createObjectURL(pngData);
const image = await loadImage(url);
if (image.width !== image.height) {
throw new Error('Image width and height do not match');
}
const size = image.width;
// Determine the formats to create
const eligibleFormats = FORMATS.filter((format) => {
// Always include the smallest size so that tiny images will get at least 1 image.
if (format.size === 16) {
return true;
}
return size >= format.size;
});
// Create a single canvas to be used for conversion
// Creating many canvases is prone to error in Safari
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('cannot get canvas rendering context');
}
const icns = new Icns.Icns();
for (const format of eligibleFormats) {
// Use the canvas to scale the image.
const formatSize = format.size;
canvas.width = formatSize;
canvas.height = formatSize;
ctx.drawImage(image, 0, 0, formatSize, formatSize);
const blob = await canvasToBlob(canvas);
const arrayBuffer = await readAsArrayBuffer(blob);
const icnsImage = await Icns.IcnsImage.fromArrayBuffer(arrayBuffer, format.type);
icns.append(icnsImage);
}
return icns.data;
};
class NWjs extends Environment {
constructor({ platform, manifest, icon }) {
super();
try {
this.manifest = JSON.parse(manifest);
} catch (e) {
throw new Error(`NW.js manifest is invalid JSON (${e}}`);
}
if (!isObject(this.manifest)) {
throw new Error('NW.js manifest not an object');
}
if (typeof this.manifest.name !== 'string') {
throw new Error('NW.js manifest is missing or has incorrect type for field: name');
}
if (/[\/\\]/g.test(this.manifest.name)) {
throw new Error('NW.js manifest name contains invalid characters');
}
if (typeof this.manifest.main !== 'string') {
throw new Error('NW.js manifest is missing or has incorrect type for field: main');
}
if (!isObject(this.manifest.window)) {
throw new Error('NW.js manifest is missing or has incorrect type for field: window');
}
if (typeof this.manifest.window.icon !== 'string') {
throw new Error('NW.js manifest is missing or has incorrect type for field: window.icon');
}
this.platform = platform;
this.icon = icon;
}
async package() {
const isWindows = this.platform === 'win64';
const isMac = this.platform === 'mac';
if (!(isWindows || isMac)) {
throw new Error('invalid platform');
}
const packagerData = await this.runtime.package(await this.projectData.asFetchedFrom('project.zip'));
const nwjsData = await fetchManifestAsset(`nwjs-${this.platform}`, (progress) => {
this.progressTarget.dispatchEvent(new CustomEvent('nwjs-progress', {
detail: progress
}));
});
const nwjsZip = await JSZip.loadAsync(nwjsData);
// NW.js Windows folder structure:
// * (root)
// +-- nwjs-v0.49.0-win-x64
// +-- nw.exe (executable)
// +-- credits.html
// +-- (project data)
//
// NW.js macOS folder structure:
// * (root)
// +-- nwjs-v0.49.0-osx-64
// +-- credits.html
// +-- nwjs.app
// +-- Contents
// +-- Resources
// +-- app.icns (icon)
// +-- app.nw
// +-- (project data)
// +-- MacOS
// +-- nwjs (executable)
// +-- ...
// the first folder, something like "nwjs-v0.49.0-win-64"
const nwjsPrefix = Object.keys(nwjsZip.files)[0].split('/')[0];
// constructor checks to make this is reasonably safe
const appName = this.manifest.name;
const zip = new JSZip();
for (const path of Object.keys(nwjsZip.files)) {
const file = nwjsZip.files[path];
let newPath = path.replace(nwjsPrefix, appName);
if (isMac) {
newPath = newPath.replace('nwjs.app', `${appName}.app`);
} else if (isWindows) {
newPath = newPath.replace('nw.exe', `${appName}.exe`);
}
// This internal hackery improves performance.
// Without this, generating the zip would take a very long time.
zip.files[newPath] = file;
}
let dataPrefix;
if (isMac) {
const icnsData = await pngToAppleICNS(this.icon);
zip.file(`${appName}/${appName}.app/Contents/Resources/app.icns`, icnsData);
dataPrefix = `${appName}/${appName}.app/Contents/Resources/app.nw/`;
} else {
dataPrefix = `${appName}/`;
}
zip.file(dataPrefix + this.manifest.main, packagerData);
zip.file(dataPrefix + 'project.zip', this.projectData.blob);
zip.file(dataPrefix + this.manifest.window.icon, this.icon);
zip.file(dataPrefix + 'package.json', JSON.stringify(this.manifest));
return {
data: await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
// Use UNIX permissions so that executable bits are properly set, which matters for macOS
platform: 'UNIX',
}),
filename: `${appName}.zip`,
};
}
}
return {
Project,
runtimes: {
TurboWarp
},
environments: {
HTML,
Zip,
NWjs,
},
};
}());