mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-14 07:39:40 +00:00
* Add resilient PDF thumbnails to GUI uploads * Keep GUI image thumbnails working with SDKs lacking the callback context The desktop loads puter.js from js.puter.com by default, and the SDK there predates the thumbnail callback context, so passing the PDF generator made every image upload lose its thumbnail until the SDK deploys. Fall back to the SDK's bundled image generator whenever the running SDK passes no usable context, and cover both paths in the unit and browser tests. * Ignore preparation failures that land after an upload is cancelled Cancelling during preparation already rejects the upload and fires the abort callback. If the step that was in flight then fails, such as a dropped directory that cannot be read, the error callback also fired and the GUI showed an upload error for an upload the user had just cancelled. Skip error reporting once preparation has been aborted. * Give each PDF thumbnail worker four seconds The per-PDF budget covers downloading PDF.js as well as rendering, and the first PDF of a session on a slower connection ran out of time before its assets had even loaded. Four seconds fits that first load on ordinary connections while staying under the five-second batch cap, so one stuck PDF still leaves the rest of the batch a chance.
409 lines
17 KiB
JavaScript
409 lines
17 KiB
JavaScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
import { encode } from 'html-entities';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import webpack from 'webpack';
|
|
import CleanCSS from 'clean-css';
|
|
import uglifyjs from 'uglify-js';
|
|
import { lib_paths, css_paths, js_paths } from './src/static-assets.js';
|
|
import { fileURLToPath } from 'url';
|
|
import BaseConfig from './webpack/BaseConfig.cjs';
|
|
import { copyPdfThumbnailAssets } from './tools/copyPdfThumbnailAssets.js';
|
|
|
|
// Polyfill __dirname, which doesn't exist in modules mode
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* Builds the application by performing various tasks such as cleaning the distribution directory,
|
|
* merging and minifying JavaScript and CSS files, bundling GUI core files, handling images,
|
|
* and preparing the final distribution package. The process involves concatenating library
|
|
* scripts, optimizing them for production, and copying essential assets to the distribution
|
|
* directory. The function also supports a verbose mode for logging detailed information during
|
|
* the build process.
|
|
*
|
|
* @param {Object} [options] - Optional parameters to customize the build process.
|
|
* @param {boolean} [options.verbose=false] - Specifies whether to log detailed information during the build.
|
|
*
|
|
* @async
|
|
* @returns {Promise<void>} A promise that resolves when the build process is complete.
|
|
*
|
|
* @example
|
|
* build({ verbose: true }).then(() => {
|
|
* console.log('Build process completed successfully.');
|
|
* }).catch(error => {
|
|
* console.error('Build process failed:', error);
|
|
* });
|
|
*/
|
|
async function build (options) {
|
|
const PUTER_ENV = process.env.PUTER_ENV || 'prod';
|
|
// -----------------------------------------------
|
|
// Delete ./dist/ directory if it exists and create a new one
|
|
// -----------------------------------------------
|
|
if ( fs.existsSync(path.join(__dirname, 'dist')) ) {
|
|
fs.rmSync(path.join(__dirname, 'dist'), { recursive: true });
|
|
}
|
|
fs.mkdirSync(path.join(__dirname, 'dist'));
|
|
await copyPdfThumbnailAssets(path.join(__dirname, 'dist'));
|
|
|
|
// -----------------------------------------------
|
|
// Concat/merge the JS libraries and save them to ./dist/libs.js
|
|
// -----------------------------------------------
|
|
let js = '';
|
|
for ( let i = 0; i < lib_paths.length; i++ ) {
|
|
const file = path.join(__dirname, 'src', lib_paths[i]);
|
|
// js
|
|
if ( file.endsWith('.js') && !file.endsWith('.min.js') ) {
|
|
let minified_code = await uglifyjs.minify(fs.readFileSync(file).toString(), { mangle: false });
|
|
if ( minified_code && minified_code.code ) {
|
|
js += minified_code.code;
|
|
if ( options?.verbose )
|
|
{
|
|
console.log('minified: ', file);
|
|
}
|
|
}
|
|
} else {
|
|
js += fs.readFileSync(file);
|
|
if ( options?.verbose )
|
|
{
|
|
console.log('skipped minification: ', file);
|
|
}
|
|
}
|
|
|
|
js += '\n\n\n';
|
|
}
|
|
|
|
// -----------------------------------------------
|
|
// Combine all images into a single js file
|
|
// -----------------------------------------------
|
|
let icons = 'window.icons = [];\n\n\n';
|
|
fs.readdirSync(path.join(__dirname, 'src/icons')).forEach(file => {
|
|
// skip dotfiles
|
|
if ( file.startsWith('.') )
|
|
{
|
|
return;
|
|
}
|
|
// load image
|
|
let buff = new Buffer.from(fs.readFileSync(`${path.join(__dirname, 'src/icons') }/${ file}`));
|
|
// convert to base64
|
|
let base64data = buff.toString('base64');
|
|
// add to `window.icons`
|
|
if ( file.endsWith('.png') )
|
|
{
|
|
icons += `window.icons['${file}'] = "data:image/png;base64,${base64data}";\n`;
|
|
}
|
|
else if ( file.endsWith('.svg') )
|
|
{
|
|
icons += `window.icons['${file}'] = "data:image/svg+xml;base64,${base64data}";\n`;
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------
|
|
// concat/merge the CSS files and save them to ./dist/bundle.min.css
|
|
// -----------------------------------------------
|
|
let css = '';
|
|
for ( let i = 0; i < css_paths.length; i++ ) {
|
|
let fullpath = path.join(__dirname, 'src', css_paths[i]);
|
|
// minify CSS files if not already minified, then concatenate
|
|
if ( css_paths[i].endsWith('.css') && !css_paths[i].endsWith('.min.css') ) {
|
|
const minified_css = new CleanCSS({}).minify(fs.readFileSync(fullpath).toString());
|
|
css += minified_css.styles;
|
|
}
|
|
// otherwise, just concatenate the file
|
|
else
|
|
{
|
|
css += fs.readFileSync(path.join(__dirname, 'src', css_paths[i]));
|
|
}
|
|
|
|
// add newlines between files
|
|
css += '\n\n\n';
|
|
}
|
|
fs.writeFileSync(path.join(__dirname, 'dist', 'bundle.min.css'), css);
|
|
|
|
// -----------------------------------------------
|
|
// Bundle GUI core and merge all files into a single JS file
|
|
// -----------------------------------------------
|
|
let main_array = [];
|
|
for ( let i = 0; i < js_paths.length; i++ ) {
|
|
main_array.push(path.join(__dirname, 'src', js_paths[i]));
|
|
}
|
|
const webpack_opts = {
|
|
...(await BaseConfig({
|
|
...options,
|
|
env: PUTER_ENV,
|
|
})),
|
|
mode: 'production',
|
|
optimization: {
|
|
minimize: true,
|
|
},
|
|
};
|
|
console.log('webpack opts', webpack_opts);
|
|
await new Promise((resolve, reject) => {
|
|
webpack(webpack_opts, (err, stats) => {
|
|
if ( err ) return reject(err);
|
|
console.log(stats.toString());
|
|
if ( stats.hasErrors() ) return reject(new Error('webpack build failed'));
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
// Copy index.js to dist/gui.js
|
|
// Prepend `window.gui_env="prod";` to `./dist/gui.js`
|
|
fs.writeFileSync(path.join(__dirname, 'dist', 'gui.js'),
|
|
`window.gui_env="${PUTER_ENV}"; \n\n${ fs.readFileSync(path.join(__dirname, 'src', 'index.js'))}`);
|
|
|
|
const copy_these = [
|
|
'images',
|
|
'fonts',
|
|
'favicons',
|
|
'browserconfig.xml',
|
|
'manifest.json',
|
|
'favicon.ico',
|
|
'security.txt',
|
|
];
|
|
|
|
const recursive_copy = (src, dest) => {
|
|
const stat = fs.statSync(src);
|
|
if ( stat.isDirectory() ) {
|
|
if ( ! fs.existsSync(dest) ) fs.mkdirSync(dest);
|
|
const files = fs.readdirSync(src);
|
|
for ( const file of files ) {
|
|
recursive_copy(path.join(src, file), path.join(dest, file));
|
|
}
|
|
} else {
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
};
|
|
|
|
for ( const to_copy of copy_these ) {
|
|
recursive_copy(path.join(__dirname, 'src', to_copy), path.join(__dirname, 'dist', to_copy));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates the HTML content for the GUI based on the specified configuration options. The function
|
|
* creates a new HTML document with the specified title, description, and other metadata. The function
|
|
* also includes the necessary CSS and JavaScript files, as well as the required meta tags for social
|
|
* media sharing and search engine optimization. The function is designed to be used in development
|
|
* environments to generate the HTML content for the GUI.
|
|
*
|
|
* @param {Object} options - The configuration options for the GUI.
|
|
* @param {string} options.env - The environment in which the GUI is running (e.g., "dev" or "prod").
|
|
* @param {string} options.api_origin - The origin of the API server.
|
|
* @param {string} options.gui_origin - The origin the GUI is served from.
|
|
* @param {string} options.app_domain - The domain apps and hosted sites live under (e.g., "puter.com").
|
|
* @param {string} options.title - The title of the GUI.
|
|
* @param {string} options.company - The name of the company or organization.
|
|
* @param {string} options.description - The description of the GUI.
|
|
* @param {string} options.app_description - The description of the application.
|
|
* @param {string} options.short_description - The short description of the GUI.
|
|
* @param {string} options.origin - The origin of the GUI.
|
|
* @param {string} options.social_card - The URL of the social media card image.
|
|
* @returns {string} The HTML content for the GUI based on the specified configuration options.
|
|
*
|
|
*/
|
|
function generateDevHtml (options) {
|
|
let start_t = Date.now();
|
|
|
|
// final html string
|
|
let h = '';
|
|
|
|
h += '<!DOCTYPE html>';
|
|
h += '<html lang="en">';
|
|
|
|
h += '<head>';
|
|
h += `<title>${encode((options.title))}</title>`;
|
|
h += `<meta name="author" content="${encode(options.company)}">`;
|
|
// description
|
|
let description = options.description;
|
|
// if app_description is set, use that instead
|
|
if ( options.app_description ) {
|
|
description = options.app_description;
|
|
}
|
|
// if no app_description is set, use short_description if set
|
|
else if ( options.short_description ) {
|
|
description = options.short_description;
|
|
}
|
|
|
|
// description
|
|
h += `<meta name="description" content="${encode((description).replace(/\n/g, ' '))}">`;
|
|
// facebook domain verification
|
|
h += '<meta name="facebook-domain-verification" content="e29w3hjbnnnypf4kzk2cewcdaxym1y" />';
|
|
// canonical url
|
|
h += `<link rel="canonical" href="${options.origin ?? options.gui_origin}">`;
|
|
|
|
// PROD: bundled stylesheet
|
|
if ( options.env === 'prod' ) {
|
|
h += '<link rel="stylesheet" href="/dist/bundle.min.css">';
|
|
}
|
|
// DEV: load every CSS file individually
|
|
else if ( options.env === 'dev' ) {
|
|
for ( let i = 0; i < css_paths.length; i++ ) {
|
|
h += `<link rel="stylesheet" href="${css_paths[i]}">`;
|
|
}
|
|
}
|
|
|
|
// Facebook meta tags
|
|
h += `<meta property="og:url" content="https://${options.domain}">`;
|
|
h += '<meta property="og:type" content="website">';
|
|
h += `<meta property="og:title" content="${encode(options.title)}">`;
|
|
h += `<meta property="og:description" content="${encode((options.short_description).replace(/\n/g, ' '))}">`;
|
|
h += `<meta property="og:image" content="${options.social_card}">`;
|
|
|
|
// Twitter meta tags
|
|
h += '<meta name="twitter:card" content="summary_large_image">';
|
|
h += `<meta property="twitter:domain" content="${options.domain}">`;
|
|
h += `<meta property="twitter:url" content="https://${options.domain}">`;
|
|
h += `<meta name="twitter:title" content="${encode(options.title)}">`;
|
|
h += `<meta name="twitter:description" content="${encode((options.short_description).replace(/\n/g, ' '))}">`;
|
|
h += `<meta name="twitter:image" content="${options.social_card}">`;
|
|
|
|
// favicons
|
|
h += `
|
|
<link rel="apple-touch-icon" sizes="57x57" href="/favicons/apple-icon-57x57.png">
|
|
<link rel="apple-touch-icon" sizes="60x60" href="/favicons/apple-icon-60x60.png">
|
|
<link rel="apple-touch-icon" sizes="72x72" href="/favicons/apple-icon-72x72.png">
|
|
<link rel="apple-touch-icon" sizes="76x76" href="/favicons/apple-icon-76x76.png">
|
|
<link rel="apple-touch-icon" sizes="114x114" href="/favicons/apple-icon-114x114.png">
|
|
<link rel="apple-touch-icon" sizes="120x120" href="/favicons/apple-icon-120x120.png">
|
|
<link rel="apple-touch-icon" sizes="144x144" href="/favicons/apple-icon-144x144.png">
|
|
<link rel="apple-touch-icon" sizes="152x152" href="/favicons/apple-icon-152x152.png">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png">
|
|
<link rel="icon" type="image/png" sizes="192x192" href="/favicons/android-icon-192x192.png">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
|
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicons/favicon-96x96.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
|
|
<link rel="manifest" href="/manifest.json">
|
|
<meta name="msapplication-TileColor" content="#ffffff">
|
|
<meta name="msapplication-TileImage" content="/favicons/ms-icon-144x144.png">
|
|
<meta name="theme-color" content="#ffffff">`;
|
|
|
|
// preload images when applicable
|
|
h += '<link rel="preload" as="image" href="./images/wallpaper.webp">';
|
|
|
|
// service-script hook: initgui resolves this promise during boot, so the
|
|
// shim must exist before the bundle runs (same shim the backend injects).
|
|
h += `
|
|
<script>
|
|
if ( ! window.service_script ) {
|
|
window.service_script_api_promise = (() => {
|
|
let resolve, reject;
|
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
promise.resolve = resolve;
|
|
promise.reject = reject;
|
|
return promise;
|
|
})();
|
|
window.service_script = async fn => {
|
|
try { await fn(await window.service_script_api_promise); }
|
|
catch (e) { console.error('service_script(ERROR)', e); }
|
|
};
|
|
}
|
|
</script>`;
|
|
|
|
h += '</head>';
|
|
|
|
h += '<body>';
|
|
|
|
// To indicate that the GUI is running to any 3rd-party scripts that may be running on the page
|
|
// specifically, the `puter.js` script
|
|
// This line is also present verbatim in `src/index.js` for production builds
|
|
h += '<script>window.puter_gui_enabled = true;</script>';
|
|
|
|
// DEV: load every JS library individually
|
|
if ( options.env === 'dev' ) {
|
|
for ( let i = 0; i < lib_paths.length; i++ ) {
|
|
h += `<script src="${lib_paths[i]}"></script>`;
|
|
}
|
|
}
|
|
|
|
// load images and icons as base64 for performance
|
|
if ( options.env === 'dev' ) {
|
|
h += '<script>';
|
|
h += 'window.icons = {};';
|
|
fs.readdirSync(path.join(__dirname, 'src/icons')).forEach(file => {
|
|
// skip dotfiles
|
|
if ( file.startsWith('.') )
|
|
{
|
|
return;
|
|
}
|
|
// load image
|
|
let buff = new Buffer.from(fs.readFileSync(`${path.join(__dirname, 'src/icons') }/${ file}`));
|
|
// convert to base64
|
|
let base64data = buff.toString('base64');
|
|
// add to `window.icons`
|
|
if ( file.endsWith('.png') )
|
|
{
|
|
h += `window.icons['${file}'] = "data:image/png;base64,${base64data}";\n`;
|
|
}
|
|
else if ( file.endsWith('.svg') )
|
|
{
|
|
h += `window.icons['${file}'] = "data:image/svg+xml;base64,${base64data}";\n`;
|
|
}
|
|
});
|
|
h += '</script>';
|
|
}
|
|
|
|
// PROD: the self-contained webpack bundle (includes the gui() bootstrap).
|
|
// `window.gui_env` must be set before gui() runs — it selects which
|
|
// puter.js build gets loaded.
|
|
if ( options.env === 'prod' ) {
|
|
h += '<script>window.gui_env = "prod";</script>';
|
|
h += '<script src="/dist/bundle.min.js"></script>';
|
|
}
|
|
// DEV: load every JS file individually
|
|
else {
|
|
for ( let i = 0; i < js_paths.length; i++ ) {
|
|
h += `<script type="module" src="${js_paths[i]}"></script>`;
|
|
}
|
|
// load GUI
|
|
h += '<script type="module" src="/index.js"></script>';
|
|
}
|
|
|
|
// ----------------------------------------
|
|
// Initialize GUI with config options
|
|
// ----------------------------------------
|
|
const guiParams = {
|
|
env: options.env,
|
|
api_origin: options.api_origin,
|
|
gui_origin: options.gui_origin,
|
|
app_origin: options.gui_origin,
|
|
app_domain: options.app_domain,
|
|
title: options.title,
|
|
max_item_name_length: options.max_item_name_length,
|
|
require_email_verification_to_publish_website: options.require_email_verification_to_publish_website,
|
|
};
|
|
const guiParamsJson = JSON.stringify(guiParams).replace(/</g, '\\u003c');
|
|
h += `
|
|
<script type="text/javascript">
|
|
window.addEventListener('load', function() {`;
|
|
h += `gui(${guiParamsJson})`;
|
|
h += `});
|
|
</script>`;
|
|
|
|
h += `</body>
|
|
|
|
</html>`;
|
|
|
|
console.log(`/index.js: ${ (Date.now() - start_t) / 1000}`);
|
|
return h;
|
|
}
|
|
|
|
// export
|
|
export { generateDevHtml, build };
|