38
loading...
This website collects cookies to deliver better user experience
Chrome File System API
. You can use this when you have a https
site or PWA
. This will make the experience of for exporting a executable more like a normal installed app.I'm figuring out how to do the protocol way and will update if I found a way of doing so.
Electron
app that can be shared. Because we are using Electron
, it makes it possible to export the project to the user's current operating system.Chrome
. This is because of their excellent Chrome File System API
. It is also doable with other browsers, but the user experience will be a lot less pollished then when you use Chrome
.Note: You can use different libraries to get the same result. But after a lot of trail and error, this what I came up with. If you know how to do this in other libraries, please share it in the comments below.
If you just want to check out the code, you can do that here → Git
cd
into it.> mkdir engine
> cd ./engine
> npx create-react-app .
> yarn add --dev @babel/cli @babel/core @babel/preset-env @babel/preset-react core-js
babel.config.json
in the root folder of our project. And this is the config you will add.{"presets": ["@babel/preset-env", "@babel/preset-react"]}
package.json
with the build command for babel."build": "del /s /q dist && babel src/Engine --out-dir dist --copy-files"
/s
and /q
from the del
command if you want to be asked if you are sure you want to rebuild. I would keep dist
unchanged for the del
and babel
commands because this is a default. You can change the src/Engine
however you want to call the folder where you have the source of your engine. But make sure it is a separate folder from the App.js
and index.js
.package.json
is setting the main
, module
, and type
. This will also depend on how you export your library. I like to have one index.js
that exports all."main": "dist/index.js",
"module": "dist/index.js",
"type": "module",
engine/src/Engine/Engine.js
import * as React from "react";
import "core-js";
const Engine = ({ data }) => {
return (<div className="App">
<h1>{data.title}</h1>
{data.images.map(image => <img src={image} alt="" width={300} key={image} />)}
</div>);
};
export default Engine;
core-js
here. Otherwise, you will have some dependency problems with Electron
later on.engine/src/App.js
import Engine from "./engine";
import projectData from "./projectData";
const App = () => {
const preProcessor = {
...projectData,
images: [...projectData.images.map(i => `/img/${i}`)]
};
return (<Engine data={preProcessor} />);
};
export default App;
App.js
is an example that you can have next to your engine library, with the purpose of easy testing without having to switch between the web app or app project. You can just use yarn start
and work on the engine like a normal React project.yarn publish
If you just want to check out the code, you can do that here → Git
Chrome File System API
. This will give your user the best experience when working with files. This is also important because we are going to need this to deliver the content files and the builder executable with it. Let me show you how I did this.Chrome File System API
and second how the engine will be used with the web app. Finally, we will return the builder to the user. This is quite a lot, so bear with me.If you want to read up on the Chrome File System API
you can do so here
web app/scr/App.js
import { useEffect, useState } from "react";
import Engine from "Engine";
const App = () => {
const [project, setProject] = useState();
const [projectData, setProjectData] = useState({
title: "This is your project",
images: []
});
const openProject = () => {
window
.showDirectoryPicker()
.then((directory) => {
setProject(directory);
return directory.values();
})
.then(async (fileHandles) => {
let projectData = undefined;
let imageDirectory = undefined;
for await (const entry of fileHandles) {
if (entry.name === "projectData.json") projectData = entry;
else if (entry.name === "img") imageDirectory = entry;
}
if (!projectData) return;
projectData
.getFile()
.then((file) => file.text())
.then((json) => JSON.parse(json))
.then(async (data) => {
const imageHandlers = await imageDirectory.values();
const images = [];
for await (const entry of imageHandlers) {
if (!data.images.includes(entry.name)) continue;
images.push(entry);
}
const newData = {
...data,
images: [...images],
};
setProjectData(newData);
});
});
};
const loadImages = () => {
if (!project) {
alert("No project folder opened")
return;
}
window.showOpenFilePicker(imagePickerOptions)
.then(images => {
setProjectData({
...projectData,
images: [
...projectData.images,
...images]
});
});
};
const saveProject = () => {
if (!project) {
alert("No project folder opened")
return;
}
project.getFileHandle('projectData.json', { create: true })
.then(newFile =>
writeFile(newFile, JSON.stringify({
...projectData,
images: [...new Set(projectData.images.map(i => i.name))]
}
)));
project.getDirectoryHandle('img', { create: true })
.then((imageFolder) => {
projectData.images.forEach((image) => {
imageFolder.getFileHandle(image.name, { create: true })
.then(newFile => {
image.getFile()
.then(file => writeFile(newFile, file));
});
});
})
};
return (
<div className="App" >
<button onClick={openProject}>Open project</button>
<button onClick={loadImages}>Load image</button>
<button onClick={saveProject}>Save project</button>
<h1>{project ? `${project.name} opened` : "No project opened yet"}</h1>
{
projectData.images.length > 0 &&
projectData.images.map(({ name }) => {
return <h2 key={name}>{`${name} opened`}</h2>
})
}
</div >
);
}
export default App;
openProject
will call window.showDirectoryPicker
which will open a directory picker. Its default behavior is to register this folder as a new project. But if it finds a projectData.json
it will try and load all the data so you can keep working on your project.loadImages
is like openProject
, but it will call window.showFilePicker
and then the user can load an image.saveProject
will save all the files that are used in the project to the project folder. Using the getFileHandle
and getDirectoryHandle
you can create directories and files in the project folder.writeFile
and writeURLToFile
you can find those implementations here. All functions from the Chrome File System API
are async and are to be awaited. If you want to publish the web app you will have to register an SSL certificate before you can use it.yarn add <-- Write the name of your engine here
web app/scr/App.js
...
const [preProcessor, setPreProcessor] = useState();
useEffect(() => {
Promise.all(projectData.images.map(i => i.getFile()
.then(f => URL.createObjectURL(f))))
.then(data => {
setPreProcessor({
...projectData,
images: [...data]
});
});
}, [projectData]);
return (
<div className="App" >
{...}
{preProcessor && <Engine data={preProcessor} />}
</div >
);
preProcessor
to be empty. So we have to check for this in the render.web app/scr/App.js
...
const buildProject = () => {
if (!project) {
alert("No project folder opened")
return;
}
project.getFileHandle('builder.exe', { create: true })
.then(newFile => writeURLToFile(newFile, `${window.location.hostname}/<-- Add the path to where your builder.exe is -->`));
};
return (
<div className="App" >
{...}
<button onClick={buildProject}>Build project</button>
{...}
</div >
);
...
Chrome File System API
it's really easy to download something to that folder. Here, I am using the writeURLToFile
function to write a file in the public folder to the user’s project folder. Currently, we don't have the builder ready, but it will be added later when we have finished the builder.If you just want to check out the code, you can do that here → Git
Electron
app template, you can follow the instructions here.file-loader
is needed to be able to pack the images in the Electron
app.yarn add --dev file-loader
webpack.renderer.config.js
so that webpack will use the file loader to access the images when the Electron
app is built. This is what the config should look like.const rules = require('./webpack.rules');
rules.push({
test: /\.css$/,
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
});
rules.push({
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
name: 'img/[name].[ext]',
publicPath: '../.'
}
},
],
});
module.exports = {
module: {
rules,
},
};
Electron
does not like you to access public files directly. That is why we have to add a local protocol to Electron
. This can be done in /src/main.js
. First import session
here const { app, BrowserWindow } = require('electron');
. Then you can add the protocol here....
const createWindow = () => {
session.defaultSession.protocol.registerFileProtocol('static', (request, callback) => {
const fileUrl = request.url.replace('static://', '');
const filePath = path.join(app.getAppPath(), '.webpack/renderer', fileUrl);
callback(filePath);
});
const mainWindow = new BrowserWindow({
...
static
to whatever you like. It is just the name of your protocol.Electron
./scr/app.jsx
import * as React from "react";
import * as ReactDOM from "react-dom";
import Engine from "Engine";
import projectData from "./projectData";
const importAll = (resource) => resource.keys().map(resource);
importAll(require.context("./img", false, /\.(png|jpe?g|gif)$/));
const preProcessor = {
...projectData,
images: [...projectData.images.map((i) => `static://img/${i}`)],
};
ReactDOM.render(
<Engine data={preProcessor} />,
document.getElementById("root")
);
static://
before the img/
. This way we can access the image files.img
folder to src
and a projectData.json
. Then you can run yarn start
first to see if it works. After that, you can verify if the build works by running yarn make
and going to the out
folder after it's finished and running the build.Electron Forge
yourself. You can configure a lot, like icons and start-up behaviors, but that is all up to you.If you just want to check out the code, you can do that here → Git
NodeJs
and the Electron
template. Because we can't be sure that the user has NodeJs
we download it for them. And the nice thing is you can execute NodeJs
in place. And the Electron
template can also easily be downloaded using the git zipball
feature. These will be placed alongside of the project, so the builder can have access to the user's project content. This will be moved to the Electron
template. And then we can use NodeJs
to execute the install dependencies command and the build command of the Electron
template. And then after a while you have a Electron
app that the user can distribute. And down here is how to do this.The builder will be written in C#
. And this has no other reason than that I am very comfortable creating executables with it. This should also be possible in NodeJS if you are using pkg or nexe. On top of that C#
can be compiled for Linux, Mac, and Windows. So you can distribute this to multiple platforms.
private const string BUILDER_TOOLS = "BuilderTools";
private const string NODE_JS = "NodeJs";
// This could be any NodeJS version you needed. Make sure it is the zip version.
private const string NODE_JS_URL = "https://nodejs.org/dist/v14.16.1/node-v14.16.1-win-x64.zip";
private const string APP_TEMPLATE = "AppTemplate";
private const string APP = "App";
private const string APP_TEMPLATE_GIT = "https://api.github.com/repos/<-- GIT USERNAME -->/<-- GIT REPO NAME -->/zipball";
private const string PROJECT_NAME = "Project";
APP_TEMPLATE_GIT
, this is why we needed to publish the app template to git. Because you can't be sure that the user has git or NPM installed, you have to get the app template another way. This is where zipball
comes in handy. Now we can just download the zip to the user, and we don't need to install anything extra on their machine./* Setting up NodeJs */
Console.WriteLine("Downloading NodeJs");
if (!Directory.Exists(BUILDER_TOOLS))
{
WebClient webClient = new();
webClient.DownloadFile(NODE_JS_URL, $".\\{BUILDER_TOOLS}.zip");
Console.WriteLine("Downloaded NodeJs");
Console.WriteLine("Extracting NodeJs");
ZipFile.ExtractToDirectory($".\\{BUILDER_TOOLS}.zip", BUILDER_TOOLS, true);
// Renaming the folder in the builder tools so it’s easier accessible
DirectoryInfo node = new($".\\{BUILDER_TOOLS}");
if (!Directory.Exists($"{node.FullName}\\{NODE_JS}"))
{
Directory.Move(node.GetDirectories()[0].FullName, $"{node.FullName}\\{NODE_JS}");
Directory.Delete(node.GetDirectories()[0].FullName);
}
File.Delete($".\\{BUILDER_TOOLS}.zip");
}
Console.WriteLine("Extracted NodeJs");
/* Setting up App template */
Console.WriteLine("Downloading App template");
if (!Directory.Exists(APP_TEMPLATE))
{
using WebClient client = new();
client.Headers.Add("user-agent", "Anything");
client.DownloadFile(APP_TEMPLATE_GIT, $".\\{APP_TEMPLATE}.zip");
Console.WriteLine("Downloaded App template");
Console.WriteLine("Extracting App template");
ZipFile.ExtractToDirectory($"{APP_TEMPLATE}.zip", APP_TEMPLATE, true);
DirectoryInfo app = new($".\\{APP_TEMPLATE}");
if (!Directory.Exists($"{app.FullName}\\{APP}"))
{
Directory.Move(app.GetDirectories()[0].FullName, $"{app.FullName}\\{APP}");
Directory.Delete(app.GetDirectories()[0].FullName);
}
//Clean up
File.Delete($"{APP_TEMPLATE}.zip");
}
Console.WriteLine("Extracted App template");
File.WriteAllBytes(@"C:\NodeJS.zip", YourProjectName.Properties.Resources.NodeJS);
C#
here. And this is how you would write it to disk./* Move the project files to the app template and overwrite if they already exist */
Console.WriteLine("Setup App template");
if (!Directory.Exists($".\\{APP_TEMPLATE}\\{APP}\\src\\img"))
Directory.CreateDirectory($".\\{APP_TEMPLATE}\\{APP}\\src\\img");
CopyFilesRecursively(".\\img", $".\\{APP_TEMPLATE}\\{APP}\\src\\img");
if (File.Exists($".\\{APP_TEMPLATE}\\{APP}\\src\\projectData.json"))
File.Delete($".\\{APP_TEMPLATE}\\{APP}\\src\\projectData.json");
File.Copy(".\\projectData.json", $".\\{APP_TEMPLATE}\\{APP}\\src\\projectData.json");
Console.WriteLine("Setup done App template");
// This is a utility function you can place outside the main function
private static void CopyFilesRecursively(string sourcePath, string targetPath)
{
foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath));
foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
File.Copy(newPath, newPath.Replace(sourcePath, targetPath), true);
}
/* Setup the package.json of the app */
Console.WriteLine("Configure App template");
string path = $".\\{APP_TEMPLATE}\\{APP}\\package.json";
string json = File.ReadAllText(path);
JObject package = JObject.Parse(json);
SaveJsonKeyEdit(package, "author", "dutchskull");
SaveJsonKeyEdit(package, "description", "An exported executable from the web");
SaveJsonKeyEdit(package, "name", PROJECT_NAME);
File.WriteAllText(path, package.ToString());
Console.WriteLine("Configure done App template");
// This is a utility function you can place outside the main function
private static void SaveJsonKeyEdit(JObject package, string key, object value)
{
if (package.ContainsKey(key))
package[key] = value.ToString();
else
package.Add(key, value.ToString());
}
Newtonsoft.Json
to your C#
project. This can be done by running this command in the terminal NuGet install Newtonsoft.Json
./* The building step */
Console.WriteLine("Building App template");
CommandExecuter.ExecuteCommand($"cd .\\{APP_TEMPLATE}\\{APP} && .\\..\\..\\{BUILDER_TOOLS}\\{NODE_JS}\\npm.cmd i");
CommandExecuter.ExecuteCommand($"cd .\\{APP_TEMPLATE}\\{APP}\\ && .\\..\\..\\{BUILDER_TOOLS}\\{NODE_JS}\\npm.cmd run make");
Console.WriteLine("Build App template");
/* Move the build to the root of the project */
DirectoryInfo buildOutputPath = new($".\\{APP_TEMPLATE}\\{APP}\\out\\make\\squirrel.windows\\x64\\");
if (File.Exists($"./{PROJECT_NAME}.exe"))
File.Delete($"./{PROJECT_NAME}.exe");
File.Move(buildOutputPath.GetFiles().Where(file => file.Name.Contains(".exe")).FirstOrDefault().FullName, $"./{PROJECT_NAME}.exe");
if (File.Exists($"{PROJECT_NAME}.exe"))
Process.Start("explorer.exe", $"{ PROJECT_NAME}.exe");
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>embedded</DebugType>
</PropertyGroup>
Electron
app for our users, we have to add it to the web app. So in the web app project, you can now add the new build builder to public/builder.exe
.38