mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
integrate electron forge
This commit is contained in:
parent
6abb3afc36
commit
f72dee731a
14 changed files with 3388 additions and 32 deletions
5
apps/x/apps/main/.gitignore
vendored
5
apps/x/apps/main/.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist/
|
||||
# Staging directory for Electron Forge packaging (contains bundled main process, copied preload/renderer)
|
||||
.package/
|
||||
out/
|
||||
80
apps/x/apps/main/agents.md
Normal file
80
apps/x/apps/main/agents.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Electron Main Process - Build & Packaging
|
||||
|
||||
## Overview
|
||||
This is the Electron main process for the Rowboat app.
|
||||
|
||||
## Why We Use esbuild Bundling
|
||||
|
||||
**Problem**: pnpm uses symlinks for workspace packages (`@x/core`, `@x/shared`).
|
||||
Electron Forge's dependency walker (`flora-colossus`) cannot follow these symlinks,
|
||||
causing "Failed to locate module" errors during packaging. Note: npm workspaces
|
||||
also use symlinks, so this isn't pnpm-specific.
|
||||
|
||||
**Solution**: Bundle the entire main process into a single JS file using esbuild.
|
||||
This inlines all dependencies (except `electron` itself), eliminating the need
|
||||
for `node_modules` at runtime.
|
||||
|
||||
## Bundle Configuration (`bundle.mjs`)
|
||||
|
||||
The bundler uses these key settings:
|
||||
|
||||
- **Format: CommonJS** - Many dependencies use `require()` which doesn't work
|
||||
with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
|
||||
- **import.meta.url polyfill** - The source code uses ESM's `import.meta.url`
|
||||
to derive `__dirname`, but CJS doesn't have `import.meta`. We solve this with:
|
||||
- `banner`: Injects `var __import_meta_url = require('url').pathToFileURL(__filename).href;`
|
||||
- `define`: Replaces all `import.meta.url` with `__import_meta_url`
|
||||
|
||||
- **External: electron** - Not bundled; provided by Electron runtime.
|
||||
|
||||
## Build Process
|
||||
|
||||
The build uses two Forge hooks in `forge.config.cjs`:
|
||||
|
||||
### 1. `generateAssets` Hook (Pre-packaging)
|
||||
Prepares all build artifacts in a hidden `.package/` staging directory:
|
||||
- Builds shared, renderer, preload, and main TypeScript
|
||||
- Bundles main process with esbuild → `.package/dist-bundle/main.js`
|
||||
- Copies preload/renderer dist to `.package/`
|
||||
|
||||
### 2. `packageAfterCopy` Hook (Post-copy)
|
||||
After Forge copies source to output, this hook fixes it:
|
||||
- Removes unbundled `dist/` (has unresolvable `@x/core` imports)
|
||||
- Copies bundled `dist-bundle/`, `preload/`, `renderer/` from staging
|
||||
- Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes
|
||||
`"type": "module"` (since we bundle as CJS), removes dependencies
|
||||
- Cleans up source files (tsconfig.json, src/, etc.)
|
||||
|
||||
**Why this approach?** Electron Forge ignores `packagerConfig.dir` and always
|
||||
packages from the config file's directory. The `packageAfterCopy` hook is the
|
||||
reliable way to customize the packaged output.
|
||||
|
||||
## Staged Build Directory (`.package/`)
|
||||
|
||||
- **Why not copy into apps/main directly?** Would pollute source with build artifacts
|
||||
- **Why .cjs extension for forge.config?** package.json has `"type": "module"`,
|
||||
but Forge loads configs with `require()`. The `.cjs` extension forces CommonJS.
|
||||
- **Why hidden (`.` prefix)?** Prevents accidental conflicts with developer-created dirs
|
||||
|
||||
## Development vs Production Paths
|
||||
|
||||
| Mode | main.js location | preload path | renderer path |
|
||||
|------|------------------|--------------|---------------|
|
||||
| Dev | `dist/main.js` | `../../preload/dist/` | `../../renderer/dist/` |
|
||||
| Prod | `dist-bundle/main.js` | `../preload/dist/` | `../renderer/dist/` |
|
||||
|
||||
Code uses `app.isPackaged` to select the correct paths at runtime.
|
||||
|
||||
## Build Commands
|
||||
|
||||
- `npm run start` - Development (runs from dist/, uses Vite dev server)
|
||||
- `npm run package` - Creates .app bundle in `out/`
|
||||
- `npm run make` - Creates DMG/ZIP in `out/make/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the packaged app fails with module errors:
|
||||
1. **Clean build**: `rm -rf out .package && npm run make`
|
||||
2. **Reinstall fresh**: Delete `/Applications/Rowboat.app` before installing DMG
|
||||
3. **Clear caches**: `rm -rf ~/Library/Caches/com.rowboat.app`
|
||||
38
apps/x/apps/main/bundle.mjs
Normal file
38
apps/x/apps/main/bundle.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Bundles the compiled main process into a single JavaScript file.
|
||||
*
|
||||
* Why we bundle:
|
||||
* - pnpm uses symlinks for workspace packages (@x/core, @x/shared)
|
||||
* - Electron Forge's dependency walker (flora-colossus) cannot follow these symlinks
|
||||
* - Bundling inlines all dependencies into a single file, eliminating node_modules
|
||||
*
|
||||
* This script is called by the generateAssets hook in forge.config.js before packaging.
|
||||
*/
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
// and we use define to replace all import.meta.url references with it.
|
||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['./dist/main.js'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist-bundle/main.js',
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
// Inject the polyfill variable at the top
|
||||
banner: { js: cjsBanner },
|
||||
// Replace import.meta.url directly with our polyfill variable
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
201
apps/x/apps/main/forge.config.cjs
Normal file
201
apps/x/apps/main/forge.config.cjs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Electron Forge config file
|
||||
// NOTE: Must be .cjs (CommonJS) because package.json has "type": "module"
|
||||
// Forge loads configs with require(), which fails on ESM files
|
||||
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
name: 'Rowboat',
|
||||
executableName: 'rowboat',
|
||||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
||||
prune: false,
|
||||
ignore: [
|
||||
// Skip any node_modules that might exist
|
||||
/node_modules/,
|
||||
// Skip source files
|
||||
/\.ts$/,
|
||||
/\.tsx$/,
|
||||
// Skip the staging directory
|
||||
/\.package/,
|
||||
// Skip the bundle script
|
||||
/bundle\.mjs$/,
|
||||
],
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-dmg',
|
||||
config: {
|
||||
format: 'ULFO',
|
||||
name: 'Rowboat',
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin'],
|
||||
// ZIP is used by Squirrel.Mac for auto-updates
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
// Hook signature: (forgeConfig, platform, arch)
|
||||
// Note: Console output only shows if DEBUG or CI env vars are set
|
||||
generateAssets: async (forgeConfig, platform, arch) => {
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const packageDir = path.join(__dirname, '.package');
|
||||
|
||||
// Clean staging directory (ensures fresh build every time)
|
||||
console.log('Cleaning staging directory...');
|
||||
if (fs.existsSync(packageDir)) {
|
||||
fs.rmSync(packageDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Build shared (TypeScript compilation)
|
||||
console.log('Building shared...');
|
||||
execSync('pnpm run build', {
|
||||
cwd: path.join(__dirname, '../../packages/shared'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// Build renderer (Vite build)
|
||||
console.log('Building renderer...');
|
||||
execSync('pnpm run build', {
|
||||
cwd: path.join(__dirname, '../renderer'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// Build preload (TypeScript compilation)
|
||||
console.log('Building preload...');
|
||||
execSync('pnpm run build', {
|
||||
cwd: path.join(__dirname, '../preload'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// Build main (TypeScript compilation)
|
||||
console.log('Building main (tsc)...');
|
||||
execSync('pnpm run build', {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// Bundle main process with esbuild (inlines all dependencies)
|
||||
console.log('Bundling main process...');
|
||||
execSync('node bundle.mjs', {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// Copy preload dist into staging directory
|
||||
console.log('Copying preload...');
|
||||
const preloadSrc = path.join(__dirname, '../preload/dist');
|
||||
const preloadDest = path.join(packageDir, 'preload/dist');
|
||||
fs.mkdirSync(preloadDest, { recursive: true });
|
||||
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
|
||||
|
||||
// Copy renderer dist into staging directory
|
||||
console.log('Copying renderer...');
|
||||
const rendererSrc = path.join(__dirname, '../renderer/dist');
|
||||
const rendererDest = path.join(packageDir, 'renderer/dist');
|
||||
fs.mkdirSync(rendererDest, { recursive: true });
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
|
||||
// Copy icons into staging directory
|
||||
console.log('Copying icons...');
|
||||
const iconsSrc = path.join(__dirname, 'icons');
|
||||
const iconsDest = path.join(packageDir, 'icons');
|
||||
if (fs.existsSync(iconsSrc)) {
|
||||
fs.mkdirSync(iconsDest, { recursive: true });
|
||||
fs.cpSync(iconsSrc, iconsDest, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate package.json in staging directory
|
||||
// This tells Electron where to find the entry point
|
||||
// Note: No "type": "module" since we bundle as CommonJS for compatibility
|
||||
// with dependencies that use dynamic require()
|
||||
const packageJson = {
|
||||
name: '@x/main',
|
||||
version: '0.1.0',
|
||||
main: 'dist-bundle/main.js',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
);
|
||||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
|
||||
// Hook runs after Forge copies source to output directory
|
||||
// We use this to replace the unbundled code with our bundled version
|
||||
// Hook signature: (forgeConfig, buildPath, electronVersion, platform, arch)
|
||||
packageAfterCopy: async (forgeConfig, buildPath, electronVersion, platform, arch) => {
|
||||
const fs = require('fs');
|
||||
const packageDir = path.join(__dirname, '.package');
|
||||
|
||||
// buildPath is the app directory inside the packaged output
|
||||
// e.g., out/Rowboat-darwin-arm64/Rowboat.app/Contents/Resources/app
|
||||
console.log('Fixing packaged app at:', buildPath);
|
||||
|
||||
// 1. Remove the unbundled dist/ directory (it has imports to @x/core, @x/shared)
|
||||
const distDir = path.join(buildPath, 'dist');
|
||||
if (fs.existsSync(distDir)) {
|
||||
console.log('Removing unbundled dist/...');
|
||||
fs.rmSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Copy the bundled dist-bundle/ from staging
|
||||
console.log('Copying bundled dist-bundle/...');
|
||||
const bundleSrc = path.join(packageDir, 'dist-bundle');
|
||||
const bundleDest = path.join(buildPath, 'dist-bundle');
|
||||
fs.cpSync(bundleSrc, bundleDest, { recursive: true });
|
||||
|
||||
// 3. Copy preload from staging
|
||||
console.log('Copying preload/...');
|
||||
const preloadSrc = path.join(packageDir, 'preload');
|
||||
const preloadDest = path.join(buildPath, 'preload');
|
||||
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
|
||||
|
||||
// 4. Copy renderer from staging
|
||||
console.log('Copying renderer/...');
|
||||
const rendererSrc = path.join(packageDir, 'renderer');
|
||||
const rendererDest = path.join(buildPath, 'renderer');
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
|
||||
// 5. Update package.json to point to bundled entry
|
||||
console.log('Updating package.json...');
|
||||
const packageJsonPath = path.join(buildPath, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
packageJson.main = 'dist-bundle/main.js';
|
||||
// Remove workspace dependencies (they're bundled now)
|
||||
delete packageJson.dependencies;
|
||||
delete packageJson.devDependencies;
|
||||
delete packageJson.scripts;
|
||||
// Remove "type": "module" - we bundle as CommonJS for compatibility
|
||||
// with dependencies that use dynamic require()
|
||||
delete packageJson.type;
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// 6. Clean up source files that shouldn't be in production
|
||||
const filesToRemove = ['tsconfig.json', 'forge.config.cjs', 'agents.md'];
|
||||
for (const file of filesToRemove) {
|
||||
const filePath = path.join(buildPath, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
}
|
||||
const srcDir = path.join(buildPath, 'src');
|
||||
if (fs.existsSync(srcDir)) {
|
||||
fs.rmSync(srcDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('✅ Packaged app fixed with bundled code');
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
apps/x/apps/main/icons/icon.icns
Normal file
BIN
apps/x/apps/main/icons/icon.icns
Normal file
Binary file not shown.
BIN
apps/x/apps/main/icons/icon.png
Normal file
BIN
apps/x/apps/main/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"name": "@x/main",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "rm -rf dist && tsc"
|
||||
"start": "electron-forge start",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/core": "workspace:*",
|
||||
|
|
@ -14,6 +18,12 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"electron": "^39.2.7"
|
||||
"electron": "^39.2.7",
|
||||
"esbuild": "^0.24.2",
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-zip": "^7.11.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { app, BrowserWindow } from "electron";
|
||||
import { app, BrowserWindow, protocol, net } from "electron";
|
||||
import path from "node:path";
|
||||
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
||||
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
|
||||
|
|
@ -13,9 +13,42 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const preloadPath = path.join(__dirname, "../../preload/dist/preload.js");
|
||||
// Path resolution differs between development and production:
|
||||
// - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir)
|
||||
// - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/)
|
||||
const preloadPath = app.isPackaged
|
||||
? path.join(__dirname, "../preload/dist/preload.js") // Production
|
||||
: path.join(__dirname, "../../preload/dist/preload.js"); // Development
|
||||
console.log("preloadPath", preloadPath);
|
||||
|
||||
// Register custom protocol for serving built renderer files in production
|
||||
function registerAppProtocol() {
|
||||
protocol.handle('app', (request) => {
|
||||
// Remove 'app://' prefix and get the path
|
||||
let urlPath = request.url.slice('app://'.length);
|
||||
|
||||
// Remove leading './' if present
|
||||
if (urlPath.startsWith('./')) {
|
||||
urlPath = urlPath.slice(2);
|
||||
}
|
||||
|
||||
// Default to index.html for root or SPA routes (no file extension)
|
||||
if (!urlPath || urlPath === '/' || !path.extname(urlPath)) {
|
||||
urlPath = 'index.html';
|
||||
}
|
||||
|
||||
// Resolve to the renderer dist directory
|
||||
// - Development: main.js at dist/, renderer at ../../renderer/dist/ (sibling dir)
|
||||
// - Production: main.js at .package/dist-bundle/, renderer at ../renderer/dist/ (copied into .package/)
|
||||
const rendererDistPath = app.isPackaged
|
||||
? path.join(__dirname, '../renderer/dist')
|
||||
: path.join(__dirname, '../../renderer/dist');
|
||||
const filePath = path.join(rendererDistPath, urlPath);
|
||||
|
||||
return net.fetch(pathToFileURL(filePath).toString());
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 800,
|
||||
|
|
@ -29,10 +62,19 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
win.loadURL("http://localhost:5173"); // load the dev server
|
||||
if (app.isPackaged) {
|
||||
// Production: load from custom protocol (serves built renderer files)
|
||||
win.loadURL('app://./');
|
||||
} else {
|
||||
// Development: load from Vite dev server
|
||||
win.loadURL('http://localhost:5173');
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Register custom protocol before creating window (for production builds)
|
||||
registerAppProtocol();
|
||||
|
||||
setupIpcHandlers();
|
||||
|
||||
createWindow();
|
||||
|
|
|
|||
|
|
@ -558,9 +558,10 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async ({ text }: PromptInputMessage) => {
|
||||
const handlePromptSubmit = async (message: PromptInputMessage) => {
|
||||
if (isProcessing) return
|
||||
|
||||
const { text } = message;
|
||||
const userMessage = text.trim()
|
||||
if (!userMessage) return
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { type PromptInputMessage } from '@/components/ai-elements/prompt-input'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
|
|
@ -106,7 +107,7 @@ interface ChatSidebarProps {
|
|||
isProcessing: boolean
|
||||
message: string
|
||||
onMessageChange: (message: string) => void
|
||||
onSubmit: (message: { text: string; files: never[] }) => void
|
||||
onSubmit: (message: PromptInputMessage) => void
|
||||
contextUsage: LanguageModelUsage
|
||||
maxTokens: number
|
||||
usedTokens: number
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: './', // Use relative paths for assets (required for Electron custom protocol)
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
|
@ -14,4 +15,7 @@ export default defineConfig({
|
|||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"name": "x",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "npm run deps && concurrently -k \"npm:renderer\" \"npm:main\"",
|
||||
"renderer": "cd apps/renderer && npm run dev",
|
||||
|
|
|
|||
3016
apps/x/pnpm-lock.yaml
generated
3016
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,4 +4,7 @@ packages:
|
|||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
- fs-xattr
|
||||
- macos-alias
|
||||
Loading…
Add table
Add a link
Reference in a new issue