Files
puter/src/gui/utils.js
T
Nariman Jelveh e6cedde732 Add resilient PDF thumbnails to GUI uploads (#3809)
* 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.
2026-09-06 13:51:02 -07:00

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 };