Add Electron desktop shell workspace
This commit is contained in:
parent
b803d10836
commit
5d8e5ea44a
16 changed files with 1652 additions and 21 deletions
|
|
@ -1,3 +1,6 @@
|
|||
# Apps
|
||||
|
||||
Next.js app(s) live here. Scaffold pending.
|
||||
User-facing app workspaces live here.
|
||||
|
||||
- `web` contains the hosted Next.js UI.
|
||||
- `desktop` contains the thin Electron shell for macOS-first internal distribution.
|
||||
|
|
|
|||
29
apps/desktop/README.md
Normal file
29
apps/desktop/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Islandflow Desktop Shell
|
||||
|
||||
This workspace packages a thin Electron shell around the hosted Islandflow app.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Loads `https://flow.deltaisland.io` by default.
|
||||
- Supports local UI development against `http://127.0.0.1:3000`.
|
||||
- Preserves the existing remote API and WebSocket behavior from the web app.
|
||||
- Keeps Electron privileges locked down for remote content.
|
||||
|
||||
## What It Does Not Do
|
||||
|
||||
- Bundle a local backend.
|
||||
- Ship a packaged local Next.js renderer in v1.
|
||||
- Add desktop-native features beyond launch, windowing, and packaging.
|
||||
|
||||
## Workspace Commands
|
||||
|
||||
- `bun run start` builds the main process and launches Electron Forge in dev mode.
|
||||
- `bun run package` creates a packaged unsigned macOS app bundle.
|
||||
- `bun run make` creates a macOS zip distributable for the current host architecture.
|
||||
- `bun run test` runs the desktop URL-policy tests.
|
||||
|
||||
## Development Notes
|
||||
|
||||
- `ISLANDFLOW_DESKTOP_START_URL` controls which trusted app URL Electron loads.
|
||||
- `NEXT_PUBLIC_API_URL` remains a web-app setting and should typically be `https://flow.deltaisland.io` when developing the local UI inside Electron.
|
||||
- `assets/` currently contains placeholders only; a real `.icns` icon is deferred.
|
||||
6
apps/desktop/assets/README.md
Normal file
6
apps/desktop/assets/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Desktop Asset Placeholders
|
||||
|
||||
This folder is reserved for the Electron shell's packaged app assets.
|
||||
|
||||
- `icon-placeholder.svg` is a visual stub only.
|
||||
- A real macOS release icon should eventually be added as `.icns` and then wired into `forge.config.ts`.
|
||||
20
apps/desktop/assets/icon-placeholder.svg
Normal file
20
apps/desktop/assets/icon-placeholder.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
|
||||
<title>Islandflow desktop placeholder icon</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#081017" />
|
||||
<stop offset="100%" stop-color="#05070a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#f5a623" />
|
||||
<stop offset="100%" stop-color="#ffd89a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="56" fill="url(#bg)" />
|
||||
<path
|
||||
d="M48 96h160v20H48zm0 44h114v20H48zm0 44h160v20H48z"
|
||||
fill="url(#accent)"
|
||||
opacity="0.94"
|
||||
/>
|
||||
<circle cx="188" cy="150" r="22" fill="#25c17a" opacity="0.95" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
17
apps/desktop/forge.config.ts
Normal file
17
apps/desktop/forge.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export default {
|
||||
packagerConfig: {
|
||||
appBundleId: "io.deltaisland.islandflow",
|
||||
appCategoryType: "public.app-category.finance",
|
||||
asar: true,
|
||||
executableName: "Islandflow",
|
||||
name: "Islandflow",
|
||||
ignore: [/^\/node_modules($|\/)/],
|
||||
prune: false
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
name: "@electron-forge/maker-zip",
|
||||
platforms: ["darwin"]
|
||||
}
|
||||
]
|
||||
};
|
||||
23
apps/desktop/package.json
Normal file
23
apps/desktop/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@islandflow/desktop",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "bun test src",
|
||||
"start": "bun run build && electron-forge start",
|
||||
"package": "bun run build && electron-forge package",
|
||||
"make": "bun run build && electron-forge make"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/core": "^7.11.1",
|
||||
"@electron-forge/maker-zip": "^7.8.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "^39.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
117
apps/desktop/src/main.ts
Normal file
117
apps/desktop/src/main.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { app, BrowserWindow, shell } from "electron";
|
||||
import type { Event as ElectronEvent } from "electron";
|
||||
|
||||
import {
|
||||
DESKTOP_PRODUCTION_URL,
|
||||
isSafeExternalUrl,
|
||||
isTrustedAppUrl,
|
||||
resolveDesktopStartUrl
|
||||
} from "./security.js";
|
||||
|
||||
const WINDOW_BACKGROUND_COLOR = "#06080b";
|
||||
const WINDOW_TITLE = "Islandflow";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
const canOpenExternalUrl = (sourceUrl: string, targetUrl: string): boolean => {
|
||||
return isTrustedAppUrl(sourceUrl) && isSafeExternalUrl(targetUrl);
|
||||
};
|
||||
|
||||
const openExternalUrl = async (sourceUrl: string, targetUrl: string): Promise<void> => {
|
||||
if (!canOpenExternalUrl(sourceUrl, targetUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openExternal(targetUrl);
|
||||
};
|
||||
|
||||
const installNavigationGuards = (window: BrowserWindow): void => {
|
||||
const { webContents } = window;
|
||||
const { session } = webContents;
|
||||
|
||||
session.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
|
||||
const handleNavigationAttempt = (event: ElectronEvent, targetUrl: string) => {
|
||||
if (isTrustedAppUrl(targetUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void openExternalUrl(webContents.getURL(), targetUrl);
|
||||
};
|
||||
|
||||
webContents.on("will-navigate", handleNavigationAttempt);
|
||||
webContents.on("will-redirect", handleNavigationAttempt);
|
||||
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
void openExternalUrl(webContents.getURL(), url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
};
|
||||
|
||||
const createMainWindow = (): BrowserWindow => {
|
||||
const window = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1200,
|
||||
minHeight: 800,
|
||||
show: false,
|
||||
title: WINDOW_TITLE,
|
||||
backgroundColor: WINDOW_BACKGROUND_COLOR,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false
|
||||
}
|
||||
});
|
||||
|
||||
installNavigationGuards(window);
|
||||
|
||||
window.once("ready-to-show", () => {
|
||||
window.show();
|
||||
});
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
const startUrl = resolveDesktopStartUrl(process.env.ISLANDFLOW_DESKTOP_START_URL);
|
||||
if (process.env.ISLANDFLOW_DESKTOP_START_URL && startUrl === DESKTOP_PRODUCTION_URL) {
|
||||
console.warn(
|
||||
`[desktop] Refused untrusted ISLANDFLOW_DESKTOP_START_URL; falling back to ${DESKTOP_PRODUCTION_URL}`
|
||||
);
|
||||
}
|
||||
|
||||
void window.loadURL(startUrl);
|
||||
return window;
|
||||
};
|
||||
|
||||
const ensureMainWindow = (): void => {
|
||||
if (mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureMainWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
ensureMainWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
41
apps/desktop/src/security.test.ts
Normal file
41
apps/desktop/src/security.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
DESKTOP_PRODUCTION_URL,
|
||||
isSafeExternalUrl,
|
||||
isTrustedAppUrl,
|
||||
resolveDesktopStartUrl
|
||||
} from "./security.js";
|
||||
|
||||
describe("desktop URL policy", () => {
|
||||
it("allows the hosted production origin", () => {
|
||||
expect(isTrustedAppUrl("https://flow.deltaisland.io/tape?symbol=SPY")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows local dev origins", () => {
|
||||
expect(isTrustedAppUrl("http://127.0.0.1:3000/signals")).toBe(true);
|
||||
expect(isTrustedAppUrl("http://localhost:3000/charts")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects untrusted origins", () => {
|
||||
expect(isTrustedAppUrl("https://example.com")).toBe(false);
|
||||
expect(isTrustedAppUrl("http://127.0.0.1:4000")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(isTrustedAppUrl("not a url")).toBe(false);
|
||||
expect(isTrustedAppUrl("javascript:alert('xss')")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats third-party http targets as external-only", () => {
|
||||
expect(isSafeExternalUrl("https://deltaisland.io/about")).toBe(true);
|
||||
expect(isSafeExternalUrl("mailto:support@deltaisland.io")).toBe(false);
|
||||
expect(isSafeExternalUrl("https://flow.deltaisland.io/help")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to production when the desktop start URL is invalid", () => {
|
||||
expect(resolveDesktopStartUrl(undefined)).toBe(DESKTOP_PRODUCTION_URL);
|
||||
expect(resolveDesktopStartUrl("https://example.com")).toBe(DESKTOP_PRODUCTION_URL);
|
||||
expect(resolveDesktopStartUrl("http://127.0.0.1:3000")).toBe("http://127.0.0.1:3000");
|
||||
});
|
||||
});
|
||||
44
apps/desktop/src/security.ts
Normal file
44
apps/desktop/src/security.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export const DESKTOP_PRODUCTION_URL = "https://flow.deltaisland.io";
|
||||
export const DESKTOP_LOCAL_DEV_URL = "http://127.0.0.1:3000";
|
||||
|
||||
const TRUSTED_ORIGINS = new Set([
|
||||
new URL(DESKTOP_PRODUCTION_URL).origin,
|
||||
new URL(DESKTOP_LOCAL_DEV_URL).origin,
|
||||
"http://localhost:3000"
|
||||
]);
|
||||
|
||||
const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
const parseUrl = (candidate: string): URL | null => {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isTrustedAppUrl = (candidate: string): boolean => {
|
||||
const url = parseUrl(candidate);
|
||||
if (!url || !HTTP_PROTOCOLS.has(url.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TRUSTED_ORIGINS.has(url.origin);
|
||||
};
|
||||
|
||||
export const isSafeExternalUrl = (candidate: string): boolean => {
|
||||
const url = parseUrl(candidate);
|
||||
if (!url || !HTTP_PROTOCOLS.has(url.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !TRUSTED_ORIGINS.has(url.origin);
|
||||
};
|
||||
|
||||
export const resolveDesktopStartUrl = (candidate: string | undefined): string => {
|
||||
if (candidate && isTrustedAppUrl(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return DESKTOP_PRODUCTION_URL;
|
||||
};
|
||||
17
apps/desktop/tsconfig.json
Normal file
17
apps/desktop/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue