1
0
silverbullet/desktop/src/instance.ts
2023-01-04 10:45:11 +01:00

242 lines
6.1 KiB
TypeScript

import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { app, BrowserWindow, dialog, Menu, MenuItem, shell } from "electron";
import portfinder from "portfinder";
import fetch from "node-fetch";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import {
newWindowState,
persistWindowState,
removeWindow,
WindowState,
} from "./store";
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
type Instance = {
folder: string;
port: number;
// Increased with "browser-window-created" event, decreased wtih "close" event
refcount: number;
proc: ChildProcessWithoutNullStreams;
};
export const runningServers = new Map<string, Instance>();
// Should work for Liux and Mac
let denoPath = `${process.resourcesPath}/deno`;
// If not...
if (!existsSync(denoPath)) {
// Windows
if (platform() === "win32") {
if (existsSync(`${process.resourcesPath}/deno.exe`)) {
denoPath = `${process.resourcesPath}/deno.exe`;
} else {
denoPath = "deno.exe";
}
} else {
// Everything else
denoPath = "deno";
}
}
async function folderPicker(): Promise<string> {
const dialogReturn = await dialog.showOpenDialog({
title: "Pick a page folder",
properties: ["openDirectory", "createDirectory"],
});
if (dialogReturn.filePaths.length === 1) {
return dialogReturn.filePaths[0];
}
}
export async function openFolderPicker() {
const folderPath = await folderPicker();
if (folderPath) {
openFolder(newWindowState(folderPath));
}
}
export async function openFolder(windowState: WindowState): Promise<void> {
const instance = await spawnInstance(windowState.folderPath);
newWindow(instance, windowState);
}
function determineSilverBulletScriptPath(): string {
let scriptPath = `${process.resourcesPath}/silverbullet.js`;
if (!existsSync(scriptPath)) {
console.log("Dev mode");
// Assumption: we're running in dev mode (npm start)
scriptPath = "../silverbullet.ts";
}
return scriptPath;
}
async function spawnInstance(pagePath: string): Promise<Instance> {
let instance = runningServers.get(pagePath);
if (instance) {
return instance;
}
// Pick random port
portfinder.setBasePort(3010);
portfinder.setHighestPort(3999);
const port = await portfinder.getPortPromise();
const proc = spawn(denoPath, [
"run",
"-A",
"--unstable",
determineSilverBulletScriptPath(),
"--port",
"" + port,
pagePath,
]);
proc.stdout.on("data", (data) => {
process.stdout.write(`[SB Out] ${data}`);
});
proc.stderr.on("data", (data) => {
process.stderr.write(`[SB Err] ${data}`);
});
proc.on("close", (code) => {
if (code) {
console.log(`child process exited with code ${code}`);
}
});
// Try for 15s to see if SB is live
for (let i = 0; i < 30; i++) {
try {
const result = await fetch(`http://localhost:${port}`);
if (result.ok) {
console.log("Live!");
instance = {
folder: pagePath,
port: port,
refcount: 0,
proc: proc,
};
runningServers.set(pagePath, instance);
return instance;
}
console.log("Still booting...");
} catch {
console.log("Still booting...");
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
// TODO: Make more specific
export function findInstanceByUrl(url: URL) {
for (const instance of runningServers.values()) {
if (instance.port === +url.port) {
return instance;
}
}
return null;
}
let quitting = false;
export function newWindow(instance: Instance, windowState: WindowState) {
// Create the browser window.
const window = new BrowserWindow({
height: windowState.height,
width: windowState.width,
x: windowState.x,
y: windowState.y,
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
},
});
instance.refcount++;
persistWindowState(windowState, window);
window.webContents.setWindowOpenHandler(({ url }) => {
const instance = findInstanceByUrl(new URL(url));
if (instance) {
newWindow(instance, newWindowState(instance.folder));
} else {
shell.openExternal(url);
}
return { action: "deny" };
});
window.webContents.on("context-menu", (event, params) => {
const menu = new Menu();
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => window.webContents.replaceMisspelling(suggestion),
}),
);
}
if (params.dictionarySuggestions.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(
new MenuItem({
label: "Add to dictionary",
click: () =>
window.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord,
),
}),
);
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ label: "Cut", role: "cut" }));
menu.append(new MenuItem({ label: "Copy", role: "copy" }));
menu.append(new MenuItem({ label: "Paste", role: "paste" }));
menu.popup();
});
window.on("resized", () => {
console.log("Reized window");
persistWindowState(windowState, window);
});
window.on("moved", () => {
persistWindowState(windowState, window);
});
window.webContents.on("did-navigate-in-page", () => {
persistWindowState(windowState, window);
});
window.once("close", () => {
console.log("Closed window");
instance.refcount--;
console.log("Refcount", instance.refcount);
if (!quitting) {
removeWindow(windowState);
}
if (instance.refcount === 0) {
console.log("Stopping server");
instance.proc.kill();
runningServers.delete(instance.folder);
}
});
window.loadURL(`http://localhost:${instance.port}${windowState.urlPath}`);
}
app.on("before-quit", () => {
console.log("Quitting");
quitting = true;
});