PoC Electron app (#264)
Initial version of Electron wrapper + build pipeline
This commit is contained in:
parent
558aee71fe
commit
fdc08d893a
79
.github/workflows/desktop.yml
vendored
Normal file
79
.github/workflows/desktop.yml
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.os }} - ${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macOS-latest
|
||||
arch: arm64
|
||||
- os: macOS-latest
|
||||
arch: x64
|
||||
- os: windows-latest
|
||||
arch: x64
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3.5.1
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
- name: Setup Deno
|
||||
# uses: denoland/setup-deno@v1
|
||||
uses: denoland/setup-deno@d4873ceeec10de6275fecd1f94b6985369d40231
|
||||
with:
|
||||
deno-version: v1.29.1
|
||||
- name: Build Silver Bullet
|
||||
run: deno task build
|
||||
- name: Create Silver Bullet bundle
|
||||
run: deno task bundle
|
||||
- name: Set MacOS signing certs
|
||||
if: matrix.os == 'macOS-latest'
|
||||
run: chmod +x scripts/add-macos-cert.sh && ./scripts/add-macos-cert.sh
|
||||
env:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
# - name: Set Windows signing certificate
|
||||
# if: matrix.os == 'windows-latest'
|
||||
# continue-on-error: true
|
||||
# id: write_file
|
||||
# uses: timheuer/base64-to-file@v1
|
||||
# with:
|
||||
# fileName: 'win-certificate.pfx'
|
||||
# encodedString: ${{ secrets.WINDOWS_CODESIGN_P12 }}
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
working-directory: desktop
|
||||
- name: Build application
|
||||
run: npm run make -- --arch=${{ matrix.arch }}
|
||||
working-directory: desktop
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
#WINDOWS_CODESIGN_FILE: ${{ steps.write_file.outputs.filePath }}
|
||||
#WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }}
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
desktop/out/**/*.deb
|
||||
desktop/out/**/*.dmg
|
||||
desktop/out/**/*Setup.exe
|
||||
desktop/out/**/*.nupkg
|
||||
desktop/out/**/*.rpm
|
||||
desktop/out/**/*.zip
|
||||
desktop/out/**/RELEASES
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
# uses: denoland/setup-deno@v1
|
||||
uses: denoland/setup-deno@d4873ceeec10de6275fecd1f94b6985369d40231
|
||||
with:
|
||||
deno-version: v1.28.1
|
||||
deno-version: v1.29.1
|
||||
|
||||
- name: Run build
|
||||
run: deno task build
|
||||
|
16
desktop/.eslintrc.json
Normal file
16
desktop/.eslintrc.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
92
desktop/.gitignore
vendored
Normal file
92
desktop/.gitignore
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
resources
|
||||
deno-download*
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.DS_Store
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
130
desktop/forge.config.ts
Normal file
130
desktop/forge.config.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import type { TargetArch } from "electron-packager";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { WebpackPlugin } from "@electron-forge/plugin-webpack";
|
||||
|
||||
import { mainConfig } from "./webpack.main.config";
|
||||
import { rendererConfig } from "./webpack.renderer.config";
|
||||
import { platform } from "node:os";
|
||||
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import decompress from "decompress";
|
||||
import { downloadFile } from "./http_util";
|
||||
|
||||
const denoVersion = "v1.29.1";
|
||||
|
||||
const denoZip: Record<string, string> = {
|
||||
"win32-x64": "deno-x86_64-pc-windows-msvc.zip",
|
||||
"darwin-x64": "deno-x86_64-apple-darwin.zip",
|
||||
"darwin-arm64": "deno-aarch64-apple-darwin.zip",
|
||||
"linux-x64": "deno-x86_64-unknown-linux-gnu.zip",
|
||||
};
|
||||
|
||||
const denoExecutableResource = platform() === "win32"
|
||||
? "resources/deno.exe"
|
||||
: "resources/deno";
|
||||
|
||||
async function downloadDeno(platform: string, arch: string): Promise<void> {
|
||||
const folder = fs.mkdtempSync("deno-download");
|
||||
const destFile = path.join(folder, "deno.zip");
|
||||
const zipFile = denoZip[`${platform}-${arch}`];
|
||||
if (!zipFile) {
|
||||
throw new Error(`No deno binary for ${platform}-${arch}`);
|
||||
}
|
||||
await downloadFile(
|
||||
`https://github.com/denoland/deno/releases/download/${denoVersion}/${zipFile}`,
|
||||
destFile,
|
||||
);
|
||||
await decompress(destFile, "resources");
|
||||
fs.rmSync(folder, { recursive: true });
|
||||
}
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
name: process.platform === "linux" ? "silverbullet" : "SilverBullet",
|
||||
executableName: process.platform === "linux"
|
||||
? "silverbullet"
|
||||
: "SilverBullet",
|
||||
icon: "../web/images/logo",
|
||||
appBundleId: "md.silverbullet",
|
||||
extraResource: [denoExecutableResource, "resources/silverbullet.js"],
|
||||
beforeCopyExtraResources: [(
|
||||
_buildPath: string,
|
||||
_electronVersion: string,
|
||||
platform: TargetArch,
|
||||
arch: TargetArch,
|
||||
callback: (err?: Error | null) => void,
|
||||
) => {
|
||||
if (fs.existsSync(denoExecutableResource)) {
|
||||
fs.rmSync(denoExecutableResource, { force: true });
|
||||
}
|
||||
Promise.resolve().then(async () => {
|
||||
// Download deno
|
||||
await downloadDeno(platform, arch);
|
||||
// Copy silverbullet.js
|
||||
fs.copyFileSync("../dist/silverbullet.js", "resources/silverbullet.js");
|
||||
}).then((r) => callback()).catch(callback);
|
||||
}],
|
||||
osxSign: true,
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ["darwin", "linux"]),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
|
||||
plugins: [
|
||||
new WebpackPlugin({
|
||||
port: 3001,
|
||||
mainConfig,
|
||||
renderer: {
|
||||
config: rendererConfig,
|
||||
|
||||
entryPoints: [
|
||||
{
|
||||
// html: "./src/index.html",
|
||||
// js: "./src/renderer.ts",
|
||||
name: "main_window",
|
||||
preload: {
|
||||
js: "./src/preload.ts",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
function notarizeMaybe() {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.CI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD) {
|
||||
console.warn(
|
||||
"Should be notarizing, but environment variables APPLE_ID or APPLE_ID_PASSWORD are missing!",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
config.packagerConfig!.osxNotarize = {
|
||||
appleId: process.env.APPLE_ID!,
|
||||
appleIdPassword: process.env.APPLE_ID_PASSWORD!,
|
||||
teamId: process.env.APPLE_TEAM_ID!,
|
||||
};
|
||||
}
|
||||
|
||||
notarizeMaybe();
|
||||
|
||||
export default config;
|
29
desktop/http_util.ts
Normal file
29
desktop/http_util.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destFile: string,
|
||||
): Promise<void> {
|
||||
const file = fs.createWriteStream(destFile);
|
||||
let response = await axios.request({
|
||||
url: url,
|
||||
method: "GET",
|
||||
responseType: "stream",
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
response.data.pipe(file);
|
||||
let error: Error | null = null;
|
||||
file.on("error", (e) => {
|
||||
error = e;
|
||||
reject(e);
|
||||
});
|
||||
file.on("close", () => {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
19081
desktop/package-lock.json
generated
Normal file
19081
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
desktop/package.json
Normal file
53
desktop/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "silverbullet",
|
||||
"version": "0.0.2",
|
||||
"description": "Markdown as a platform",
|
||||
"main": ".webpack/main",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"clean": "rm -rf out"
|
||||
},
|
||||
"keywords": [],
|
||||
"repository": "github:silverbulletmd/silverbullet",
|
||||
"author": {
|
||||
"name": "Zef Hemel",
|
||||
"email": "zef@zef.me"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^6.0.4",
|
||||
"@electron-forge/maker-deb": "^6.0.4",
|
||||
"@electron-forge/maker-rpm": "^6.0.4",
|
||||
"@electron-forge/maker-squirrel": "^6.0.4",
|
||||
"@electron-forge/maker-zip": "^6.0.4",
|
||||
"@electron-forge/plugin-webpack": "^6.0.4",
|
||||
"@types/decompress": "^4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.47.1",
|
||||
"@vercel/webpack-asset-relocator-loader": "^1.7.3",
|
||||
"css-loader": "^6.7.3",
|
||||
"electron": "22.0.0",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"fork-ts-checker-webpack-plugin": "^7.2.14",
|
||||
"node-loader": "^2.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~4.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-forge/publisher-github": "^6.0.4",
|
||||
"axios": "^1.2.2",
|
||||
"decompress": "^4.2.1",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"node-fetch": "^3.3.0",
|
||||
"portfinder": "^1.0.32",
|
||||
"update-electron-app": "^2.0.1"
|
||||
}
|
||||
}
|
56
desktop/src/index.ts
Normal file
56
desktop/src/index.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { app, BrowserWindow, Menu } from "electron";
|
||||
import { openFolder, openFolderPicker } from "./instance";
|
||||
import { menu } from "./menu";
|
||||
import { getOpenWindows } from "./store";
|
||||
|
||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
|
||||
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
|
||||
// whether you're running in development or production).
|
||||
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
|
||||
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (require("electron-squirrel-startup")) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// Auto updater
|
||||
require("update-electron-app")();
|
||||
|
||||
async function boot() {
|
||||
const openWindows = getOpenWindows();
|
||||
if (openWindows.length === 0) {
|
||||
await openFolderPicker();
|
||||
} else {
|
||||
for (const window of openWindows) {
|
||||
// Doing this sequentially to avoid race conditions in starting servers
|
||||
await openFolder(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on("ready", () => {
|
||||
Menu.setApplicationMenu(menu);
|
||||
console.log("App data path", app.getPath("userData"));
|
||||
boot().catch(console.error);
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
boot();
|
||||
}
|
||||
});
|
247
desktop/src/instance.ts
Normal file
247
desktop/src/instance.ts
Normal file
@ -0,0 +1,247 @@
|
||||
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)
|
||||
return "../silverbullet.ts";
|
||||
}
|
||||
const userData = app.getPath("userData");
|
||||
if (existsSync(`${userData}/silverbullet.js`)) {
|
||||
// Custom downloaded (upgraded) version
|
||||
scriptPath = `${userData}/silverbullet.js`;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
161
desktop/src/menu.ts
Normal file
161
desktop/src/menu.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { app, Menu, MenuItemConstructorOptions, shell } from "electron";
|
||||
import { findInstanceByUrl, newWindow, openFolderPicker } from "./instance";
|
||||
import { newWindowState } from "./store";
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "File",
|
||||
role: "fileMenu",
|
||||
submenu: [
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "CommandOrControl+N",
|
||||
click: (_item, win) => {
|
||||
const url = new URL(win.webContents.getURL());
|
||||
const instance = findInstanceByUrl(url);
|
||||
if (instance) {
|
||||
newWindow(instance, newWindowState(instance.folder));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Open Space",
|
||||
accelerator: "CommandOrControl+Shift+O",
|
||||
click: () => {
|
||||
openFolderPicker();
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "CommandOrControl+Q",
|
||||
role: "quit",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
role: "editMenu",
|
||||
submenu: [
|
||||
{
|
||||
label: "Undo",
|
||||
accelerator: "CommandOrControl+Z",
|
||||
role: "undo",
|
||||
},
|
||||
{
|
||||
label: "Redo",
|
||||
accelerator: "Shift+CommandOrControl+Z",
|
||||
role: "redo",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Cut",
|
||||
accelerator: "CommandOrControl+X",
|
||||
role: "cut",
|
||||
},
|
||||
{
|
||||
label: "Copy",
|
||||
accelerator: "CommandOrControl+C",
|
||||
role: "copy",
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
accelerator: "CommandOrControl+V",
|
||||
role: "paste",
|
||||
},
|
||||
{
|
||||
label: "Paste and match style",
|
||||
accelerator: "CommandOrControl+Shift+V",
|
||||
role: "pasteAndMatchStyle",
|
||||
},
|
||||
{
|
||||
label: "Select All",
|
||||
accelerator: "CommandOrControl+A",
|
||||
role: "selectAll",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Navigate",
|
||||
submenu: [
|
||||
{
|
||||
label: "Home",
|
||||
accelerator: "Alt+h",
|
||||
click: (_item, win) => {
|
||||
win.loadURL(new URL(win.webContents.getURL()).origin);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reload",
|
||||
accelerator: "CommandOrControl+r",
|
||||
role: "forceReload",
|
||||
},
|
||||
{
|
||||
label: "Back",
|
||||
accelerator: "CommandOrControl+[",
|
||||
click: (_item, win) => {
|
||||
win.webContents.goBack();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Forward",
|
||||
accelerator: "CommandOrControl+]",
|
||||
click: (_item, win) => {
|
||||
win.webContents.goForward();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Develop",
|
||||
submenu: [
|
||||
{
|
||||
label: "Open in Browser",
|
||||
click: (_item, win) => {
|
||||
shell.openExternal(win.webContents.getURL());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Open Space Folder",
|
||||
click: (_item, win) => {
|
||||
let url = win.webContents.getURL();
|
||||
shell.openPath(findInstanceByUrl(new URL(url)).folder);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Toggle Dev Tools",
|
||||
accelerator: "CommandOrControl+Alt+J",
|
||||
role: "toggleDevTools",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
role: "windowMenu",
|
||||
submenu: [
|
||||
{
|
||||
label: "Minimize",
|
||||
accelerator: "CommandOrControl+M",
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: "Maximize",
|
||||
click: (_item, win) => {
|
||||
win.maximize();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: "CommandOrControl+W",
|
||||
role: "close",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const name = app.getName();
|
||||
template.unshift({ label: name, submenu: [] });
|
||||
}
|
||||
|
||||
export const menu = Menu.buildFromTemplate(template);
|
3
desktop/src/preload.ts
Normal file
3
desktop/src/preload.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
console.log("Yo, I'm preload.ts!");
|
31
desktop/src/renderer.ts
Normal file
31
desktop/src/renderer.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* This file will automatically be loaded by webpack and run in the "renderer" context.
|
||||
* To learn more about the differences between the "main" and the "renderer" context in
|
||||
* Electron, visit:
|
||||
*
|
||||
* https://electronjs.org/docs/latest/tutorial/process-model
|
||||
*
|
||||
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
|
||||
* in a renderer process, please be aware of potential security implications. You can read
|
||||
* more about security risks here:
|
||||
*
|
||||
* https://electronjs.org/docs/tutorial/security
|
||||
*
|
||||
* To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
|
||||
* flag:
|
||||
*
|
||||
* ```
|
||||
* // Create the browser window.
|
||||
* mainWindow = new BrowserWindow({
|
||||
* width: 800,
|
||||
* height: 600,
|
||||
* webPreferences: {
|
||||
* nodeIntegration: true
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
console.log('👋 This message is being logged by "renderer.js", included via webpack');
|
79
desktop/src/store.ts
Normal file
79
desktop/src/store.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import Store from "electron-store";
|
||||
|
||||
export type WindowState = {
|
||||
id: string; // random GUID
|
||||
width: number;
|
||||
height: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
folderPath: string;
|
||||
urlPath: string;
|
||||
};
|
||||
|
||||
const store = new Store({
|
||||
defaults: {
|
||||
openWindows: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function getOpenWindows(): WindowState[] {
|
||||
return store.get("openWindows");
|
||||
}
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function newWindowState(folderPath: string): WindowState {
|
||||
return {
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
folderPath,
|
||||
urlPath: "/",
|
||||
};
|
||||
}
|
||||
|
||||
export function persistWindowState(
|
||||
windowState: WindowState,
|
||||
window: BrowserWindow,
|
||||
) {
|
||||
const [width, height] = window.getSize();
|
||||
const [x, y] = window.getPosition();
|
||||
windowState.height = height;
|
||||
windowState.width = width;
|
||||
windowState.x = x;
|
||||
windowState.y = y;
|
||||
const urlString = window.webContents.getURL();
|
||||
if (urlString) {
|
||||
windowState.urlPath = new URL(urlString).pathname;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
const newWindows = getOpenWindows().map((win) => {
|
||||
if (win.id === windowState.id) {
|
||||
found = true;
|
||||
return windowState;
|
||||
} else {
|
||||
return win;
|
||||
}
|
||||
});
|
||||
if (!found) {
|
||||
newWindows.push(windowState);
|
||||
}
|
||||
store.set(
|
||||
"openWindows",
|
||||
newWindows,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeWindow(windowState: WindowState) {
|
||||
const newWindows = getOpenWindows().filter((win) =>
|
||||
win.id !== windowState.id
|
||||
);
|
||||
store.set(
|
||||
"openWindows",
|
||||
newWindows,
|
||||
);
|
||||
}
|
19
desktop/tsconfig.json
Normal file
19
desktop/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"module": "commonjs",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"*": ["node_modules/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
18
desktop/webpack.main.config.ts
Normal file
18
desktop/webpack.main.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Configuration } from 'webpack';
|
||||
|
||||
import { rules } from './webpack.rules';
|
||||
|
||||
export const mainConfig: Configuration = {
|
||||
/**
|
||||
* This is the main entry point for your application, it's the first file
|
||||
* that runs in the main process.
|
||||
*/
|
||||
entry: './src/index.ts',
|
||||
// Put your normal webpack config below here
|
||||
module: {
|
||||
rules,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'],
|
||||
},
|
||||
};
|
12
desktop/webpack.plugins.ts
Normal file
12
desktop/webpack.plugins.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type IForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ForkTsCheckerWebpackPlugin: typeof IForkTsCheckerWebpackPlugin = require(
|
||||
"fork-ts-checker-webpack-plugin",
|
||||
);
|
||||
|
||||
export const plugins = [
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
logger: "webpack-infrastructure",
|
||||
}),
|
||||
];
|
19
desktop/webpack.renderer.config.ts
Normal file
19
desktop/webpack.renderer.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { Configuration } from "webpack";
|
||||
|
||||
import { rules } from "./webpack.rules";
|
||||
import { plugins } from "./webpack.plugins";
|
||||
|
||||
rules.push({
|
||||
test: /\.css$/,
|
||||
use: [{ loader: "style-loader" }, { loader: "css-loader" }],
|
||||
});
|
||||
|
||||
export const rendererConfig: Configuration = {
|
||||
module: {
|
||||
rules,
|
||||
},
|
||||
plugins,
|
||||
resolve: {
|
||||
extensions: [".js", ".ts", ".jsx", ".tsx", ".css"],
|
||||
},
|
||||
};
|
31
desktop/webpack.rules.ts
Normal file
31
desktop/webpack.rules.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { ModuleOptions } from 'webpack';
|
||||
|
||||
export const rules: Required<ModuleOptions>['rules'] = [
|
||||
// Add support for native node modules
|
||||
{
|
||||
// We're specifying native_modules in the test because the asset relocator loader generates a
|
||||
// "fake" .node file which is really a cjs file.
|
||||
test: /native_modules[/\\].+\.node$/,
|
||||
use: 'node-loader',
|
||||
},
|
||||
{
|
||||
test: /[/\\]node_modules[/\\].+\.(m?js|node)$/,
|
||||
parser: { amd: false },
|
||||
use: {
|
||||
loader: '@vercel/webpack-asset-relocator-loader',
|
||||
options: {
|
||||
outputAssetBase: 'native_modules',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /(node_modules|\.webpack)/,
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
23
scripts/add-macos-cert.sh
Executable file
23
scripts/add-macos-cert.sh
Executable file
@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
KEY_CHAIN=build.keychain
|
||||
MACOS_CERT_P12_FILE=certificate.p12
|
||||
|
||||
# Recreate the certificate from the secure environment variable
|
||||
echo $MACOS_CERT_P12 | base64 --decode > $MACOS_CERT_P12_FILE
|
||||
|
||||
#create a keychain
|
||||
security create-keychain -p actions $KEY_CHAIN
|
||||
|
||||
# Make the keychain the default so identities are found
|
||||
security default-keychain -s $KEY_CHAIN
|
||||
|
||||
# Unlock the keychain
|
||||
security unlock-keychain -p actions $KEY_CHAIN
|
||||
|
||||
security import $MACOS_CERT_P12_FILE -k $KEY_CHAIN -P $MACOS_CERT_PASSWORD -T /usr/bin/codesign;
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN
|
||||
|
||||
# remove certs
|
||||
rm -fr *.p12
|
BIN
web/images/logo.icns
Normal file
BIN
web/images/logo.icns
Normal file
Binary file not shown.
@ -155,7 +155,7 @@ For the sake of simplicity, we will use the `page` data source and limit the res
|
||||
|--|--|--|--|--|--|--|--|--|
|
||||
|Markdown |1669534332564|text/markdown|1022|rw| | | | |
|
||||
|🔌 Graph View|1669388320673|text/markdown|1042|rw|plug|github:bbroeksema/silverbullet-graphview/graphview.plug.json|https://github.com/bbroeksema/silverbullet-graphview|Bertjan Broeksema|
|
||||
|SETTINGS |1667053645895|text/markdown|169 |rw| | | | |
|
||||
|SETTINGS |1671107145991|text/markdown|169 |rw| | | | |
|
||||
<!-- /query -->
|
||||
|
||||
|
||||
@ -166,13 +166,13 @@ For the sake of simplicity, we will use the `page` data source and limit the res
|
||||
**Result:** Okay, this is what we wanted but there is also information such as `perm`, `type` and `lastModified` that we don't need.
|
||||
|
||||
<!-- #query page where type = "plug" order by lastModified desc limit 5 -->
|
||||
|name |lastModified |contentType |size|perm|type|repo |uri |author |share-support|
|
||||
|name |lastModified |contentType |size|perm|type|uri |repo |author |share-support|
|
||||
|--|--|--|--|--|--|--|--|--|--|
|
||||
|🔌 Directive|1671044429696|text/markdown|2605|rw|plug|https://github.com/silverbulletmd/silverbullet | | | |
|
||||
|🔌 Backlinks|1670833065065|text/markdown|960 |rw|plug|https://github.com/Willyfrog/silverbullet-backlinks|ghr:Willyfrog/silverbullet-backlinks|Guillermo Vayá| |
|
||||
|🔌 Collab |1670435068917|text/markdown|2923|rw|plug|https://github.com/silverbulletmd/silverbullet | | |true|
|
||||
|🔌 Tasks |1669536555227|text/markdown|1231|rw|plug|https://github.com/silverbulletmd/silverbullet | | | |
|
||||
|🔌 Share |1669536545411|text/markdown|672 |rw|plug|https://github.com/silverbulletmd/silverbullet | | | |
|
||||
|🔌 KaTeX |1671723760117|text/markdown|1346|rw|plug|github:silverbulletmd/silverbullet-katex/katex.plug.json |https://github.com/silverbulletmd/silverbullet-katex |Zef Hemel| |
|
||||
|🔌 Mermaid |1671723720005|text/markdown|1501|rw|plug|github:silverbulletmd/silverbullet-mermaid/mermaid.plug.json |https://github.com/silverbulletmd/silverbullet-mermaid |Zef Hemel| |
|
||||
|🔌 Mattermost|1671205865185|text/markdown|3535|rw|plug|github:silverbulletmd/silverbullet-mattermost/mattermost.plug.json|https://github.com/silverbulletmd/silverbullet-mattermost|Zef Hemel|true|
|
||||
|🔌 Share |1671205498955|text/markdown|694 |rw|plug| |https://github.com/silverbulletmd/silverbullet | | |
|
||||
|🔌 Directive |1671044959953|text/markdown|2605|rw|plug| |https://github.com/silverbulletmd/silverbullet | | |
|
||||
<!-- /query -->
|
||||
|
||||
#### 6.3 Query to select only certain fields
|
||||
@ -183,14 +183,14 @@ and `repo` columns and then sort by last modified time.
|
||||
**Result:** Okay, this is much better. However, I believe this needs a touch
|
||||
from a visual perspective.
|
||||
|
||||
<!-- #query page select name author repo uririri where type = "plug" order by lastModified desc limit 5 -->
|
||||
|name |author |repo |ri|
|
||||
<!-- #query page select name author repo uririrririrririrririrririri where type = "plug" order by lastModified desc limit 5 -->
|
||||
|name |author |repo |ririrririrririri|
|
||||
|--|--|--|--|
|
||||
|🔌 Directive| |https://github.com/silverbulletmd/silverbullet ||
|
||||
|🔌 Backlinks|Guillermo Vayá|https://github.com/Willyfrog/silverbullet-backlinks||
|
||||
|🔌 Collab | |https://github.com/silverbulletmd/silverbullet ||
|
||||
|🔌 Tasks | |https://github.com/silverbulletmd/silverbullet ||
|
||||
|🔌 Share | |https://github.com/silverbulletmd/silverbullet ||
|
||||
|🔌 KaTeX |Zef Hemel|https://github.com/silverbulletmd/silverbullet-katex ||
|
||||
|🔌 Mermaid |Zef Hemel|https://github.com/silverbulletmd/silverbullet-mermaid ||
|
||||
|🔌 Mattermost|Zef Hemel|https://github.com/silverbulletmd/silverbullet-mattermost||
|
||||
|🔌 Share | |https://github.com/silverbulletmd/silverbullet ||
|
||||
|🔌 Directive | |https://github.com/silverbulletmd/silverbullet ||
|
||||
<!-- /query -->
|
||||
|
||||
#### 6.4 Display the data in a format defined by a template
|
||||
@ -199,12 +199,12 @@ from a visual perspective.
|
||||
|
||||
**Result:** Here you go. This is the result we would like to achieve 🎉. Did you see how I used `render` and `template/plug` in a query? 🚀
|
||||
|
||||
<!-- #query page select name author repo uririri where type = "plug" order by lastModified desc limit 5 render [[template/plug]] -->
|
||||
<!-- #query page select name author repo uririrririrririrririrririri where type = "plug" order by lastModified desc limit 5 render [[template/plug]] -->
|
||||
* [[🔌 KaTeX]] by **Zef Hemel** ([repo](https://github.com/silverbulletmd/silverbullet-katex))
|
||||
* [[🔌 Mermaid]] by **Zef Hemel** ([repo](https://github.com/silverbulletmd/silverbullet-mermaid))
|
||||
* [[🔌 Mattermost]] by **Zef Hemel** ([repo](https://github.com/silverbulletmd/silverbullet-mattermost))
|
||||
* [[🔌 Share]]
|
||||
* [[🔌 Directive]]
|
||||
* [[🔌 Backlinks]] by **Guillermo Vayá** ([repo](https://github.com/Willyfrog/silverbullet-backlinks))
|
||||
* [[🔌 Collab]]
|
||||
* [[🔌 Tasks]]
|
||||
* [[🔌 Share]]
|
||||
<!-- /query -->
|
||||
|
||||
PS: You don't need to select only certain fields to use templates. Templates are
|
||||
|
Loading…
Reference in New Issue
Block a user