1
0

PoC Electron app (#264)

Initial version of Electron wrapper + build pipeline
This commit is contained in:
Zef Hemel 2023-01-03 20:46:52 +01:00 committed by GitHub
parent 558aee71fe
commit fdc08d893a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 20199 additions and 20 deletions

79
.github/workflows/desktop.yml vendored Normal file
View 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

View File

@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

53
desktop/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/**/*"]
}

View 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'],
},
};

View 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",
}),
];

View 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
View 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
View 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

Binary file not shown.

View File

@ -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