diff --git a/app/editor/layout.tsx b/app/editor/layout.tsx
index 5416ed6..f8cfcbe 100644
--- a/app/editor/layout.tsx
+++ b/app/editor/layout.tsx
@@ -8,14 +8,14 @@ import TutorialOverlay from './components/overlays/TutorialOverlay'
import { useStore } from '@/app/store/useStore'
export default function EditorLayout({ children }: { children: React.ReactNode }) {
- const { uiVisible } = useStore() // Get global UI state
+ const { uiVisible, editorMode } = useStore() // Get global UI state and editor mode
return (
<>
@@ -26,7 +26,7 @@ export default function EditorLayout({ children }: { children: React.ReactNode }
diff --git a/app/editor/page.tsx b/app/editor/page.tsx
index 5d3d0e7..e107200 100644
--- a/app/editor/page.tsx
+++ b/app/editor/page.tsx
@@ -2,18 +2,37 @@
import Stage from '@/app/components/Stage';
import ModeSwitcher from '@/app/components/ModeSwitcher';
-import ExportModal from '@/app/editor/components/modals/ExportModal';
-import SettingsModal from '@/app/editor/components/modals/SettingsModal';
-import ModelsModal from '@/app/editor/components/modals/ModelsModal';
-import Header from './components/layouts/Header';
-import Timeline from './components/layouts/Timeline';
-import { useModal } from './store/useModal';
-import { useEffect } from 'react';
+import BuilderToolbar from './components/layouts/BuilderToolbar';
+import BuilderSidebar from './components/layouts/BuilderSidebar';
+import { useStore } from '@/app/store/useStore';
+import { useEffect, Suspense } from 'react';
+import { useSearchParams } from 'next/navigation';
-export default function EditorPage() {
- const { closeModal } = useModal();
+function EditorContent() {
+ const { setEditorMode, editorMode, initBuilderFigure } = useStore();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ const mode = searchParams.get('mode');
+ if (mode === 'figure') {
+ setEditorMode('figure');
+ initBuilderFigure();
+ } else {
+ setEditorMode('anime');
+ }
+ }, [searchParams, setEditorMode, initBuilderFigure]);
+
return <>
-
+ {editorMode === 'anime' ? : }
+ {editorMode === 'figure' && }
>;
}
+
+export default function EditorPage() {
+ return (
+ Loading...
}>
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 8717be0..165089f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,11 +6,12 @@ import { useRouter } from 'next/navigation';
import UpdateNotification from '@/app/components/UpdateNotification';
export default function Home() {
- const fileInputRef = useRef
(null);
+ const projectFileInputRef = useRef(null);
+ const figureFileInputRef = useRef(null);
const { setProject } = useStore();
const router = useRouter();
- const handleFileUpload = (e: React.ChangeEvent) => {
+ const handleProjectFileUpload = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -28,13 +29,50 @@ export default function Home() {
reader.readAsText(file);
};
+ const handleFigureFileUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ try {
+ const content = event.target?.result as string;
+ const figure = JSON.parse(content);
+
+ // Validate figure structure
+ if (!figure.id || !figure.root_pivot || !Array.isArray(figure.shapes)) {
+ alert('올바른 .psfigure 파일이 아닙니다.');
+ return;
+ }
+
+ // Save to custom figures
+ const customFigures = JSON.parse(localStorage.getItem('customFigures') || '{}');
+ const figureId = `imported-${Date.now()}`;
+ const fileName = file.name.replace('.psfigure', '');
+
+ customFigures[figureId] = {
+ id: figureId,
+ name: fileName,
+ figure: figure,
+ createdAt: Date.now()
+ };
+
+ localStorage.setItem('customFigures', JSON.stringify(customFigures));
+ alert(`'${fileName}' 피규어가 저장되었습니다!`);
+ } catch {
+ alert('파일을 읽을 수 없습니다. 올바른 .psfigure 파일인지 확인해주세요.');
+ }
+ };
+ reader.readAsText(file);
+ };
+
return (
Pivot Station
-
+
+
+
+
+ {"> " }커스텀 피규어 제작
+
+
+ 나만의 피규어를 만들어요.
+
+
+
+
+
+
+
+
diff --git a/app/store/useStore.ts b/app/store/useStore.ts
index d859203..a457617 100644
--- a/app/store/useStore.ts
+++ b/app/store/useStore.ts
@@ -1,5 +1,12 @@
import { create } from 'zustand';
-import { Project, Frame, Figure } from '@/app/types';
+import { Project, Frame, Figure, Pivot, Shape } from '@/app/types';
+
+export type BuilderTool = 'select' | 'add-pivot' | 'connect' | 'set-root' | 'set-joint' | 'set-fixed';
+export type ValidationError = {
+ type: 'isolated-pivot' | 'invalid-joint' | 'no-root';
+ pivotId?: string;
+ message: string;
+};
interface AppState {
project: Project;
@@ -12,8 +19,19 @@ interface AppState {
interactionMode: 'rotate' | 'stretch' | 'flip';
globalThickness: number;
uiVisible: boolean; // New state
+ editorMode: 'anime' | 'figure'; // New state
+
+ // Builder state
+ builderFigure: Figure | null;
+ builderTool: BuilderTool;
+ selectedPivotIds: string[];
+ builderShapeType: 'line' | 'circle' | 'polygon' | 'curve';
+ validationErrors: ValidationError[];
+ builderRootPivotId: string | null;
+ connectingPivots: string[]; // For connect mode - stores sequence of pivots being connected
// Actions
+ setEditorMode: (mode: 'anime' | 'figure') => void;
toggleUiVisible: () => void; // New action
setGlobalThickness: (thickness: number) => void;
setInteractionMode: (mode: 'rotate' | 'stretch' | 'flip') => void;
@@ -33,6 +51,28 @@ interface AppState {
loadFromLocalStorage: (projectId: string) => boolean;
getAllProjects: () => Array<{ id: string; name: string; description: string; lastModified: number }>;
deleteProject: (projectId: string) => void;
+
+ // Builder actions
+ initBuilderFigure: () => void;
+ setBuilderTool: (tool: BuilderTool) => void;
+ addBuilderPivot: (x: number, y: number, parentId?: string) => void;
+ removeBuilderPivot: (pivotId: string) => void;
+ moveBuilderPivot: (pivotId: string, x: number, y: number) => void;
+ togglePivotSelection: (pivotId: string) => void;
+ clearPivotSelection: () => void;
+ setBuilderShapeType: (type: 'line' | 'circle' | 'polygon' | 'curve') => void;
+ addBuilderShape: () => void;
+ removeBuilderShape: (shapeIndex: number) => void;
+ setBuilderPivotType: (pivotId: string, type: 'joint' | 'fixed') => void;
+ setBuilderRootPivot: (pivotId: string) => void;
+ validateBuilderFigure: () => void;
+ saveBuilderFigure: (name: string) => void;
+ loadBuilderFigure: (figureId: string) => void;
+ exportBuilderFigure: (name: string) => void;
+ resetBuilderFigure: () => void;
+ addConnectingPivot: (pivotId: string) => void;
+ clearConnectingPivots: () => void;
+ createLineFromConnecting: () => void;
}
const createInitialProject = (): Project => ({
@@ -58,7 +98,18 @@ export const useStore = create
((set, get) => ({
interactionMode: 'rotate',
globalThickness: 4,
uiVisible: true,
+ editorMode: 'anime',
+
+ // Builder state initialization
+ builderFigure: null,
+ builderTool: 'select',
+ selectedPivotIds: [],
+ builderShapeType: 'line',
+ validationErrors: [],
+ builderRootPivotId: null,
+ connectingPivots: [],
+ setEditorMode: (mode) => set({ editorMode: mode }),
toggleUiVisible: () => set((state) => ({ uiVisible: !state.uiVisible })),
setGlobalThickness: (thickness) => set({ globalThickness: thickness }),
setInteractionMode: (mode) => set({ interactionMode: mode }),
@@ -194,4 +245,345 @@ export const useStore = create((set, get) => ({
}),
togglePlay: () => set((state) => ({ isPlaying: !state.isPlaying })),
+
+ // Builder actions
+ initBuilderFigure: () => set({
+ builderFigure: {
+ id: `figure-${Date.now()}`,
+ // Wrapper root to allow multiple top-level roots (forest)
+ root_pivot: {
+ id: 'root-container',
+ type: 'fixed',
+ x: 640,
+ y: 360,
+ children: []
+ },
+ shapes: [],
+ color: '#000000',
+ opacity: 1,
+ thickness: 4
+ },
+ builderRootPivotId: 'root-container',
+ selectedPivotIds: [],
+ validationErrors: []
+ }),
+
+ setBuilderTool: (tool) => set({ builderTool: tool }),
+
+ addBuilderPivot: (x, y, parentId) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const newPivot: Pivot = {
+ id: `pivot-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ type: 'joint',
+ x,
+ y,
+ children: []
+ };
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ if (parentId) {
+ // Find parent and add as child
+ const findAndAddChild = (pivot: Pivot): boolean => {
+ if (pivot.id === parentId) {
+ pivot.children.push(newPivot);
+ return true;
+ }
+ for (const child of pivot.children) {
+ if (findAndAddChild(child)) return true;
+ }
+ return false;
+ };
+ findAndAddChild(figure.root_pivot);
+ } else {
+ // Add as child of root
+ figure.root_pivot.children.push(newPivot);
+ }
+
+ return { builderFigure: figure };
+ }),
+
+ removeBuilderPivot: (pivotId) => set((state) => {
+ if (!state.builderFigure || pivotId === state.builderRootPivotId) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ // Remove pivot from hierarchy
+ const removeFromHierarchy = (pivot: Pivot): boolean => {
+ pivot.children = pivot.children.filter(child => {
+ if (child.id === pivotId) return false;
+ removeFromHierarchy(child);
+ return true;
+ });
+ return true;
+ };
+ removeFromHierarchy(figure.root_pivot);
+
+ // Remove shapes containing this pivot
+ figure.shapes = figure.shapes.filter(shape => !shape.pivotIds.includes(pivotId));
+
+ return {
+ builderFigure: figure,
+ selectedPivotIds: state.selectedPivotIds.filter(id => id !== pivotId)
+ };
+ }),
+
+ togglePivotSelection: (pivotId) => set((state) => {
+ const isSelected = state.selectedPivotIds.includes(pivotId);
+ return {
+ selectedPivotIds: isSelected
+ ? state.selectedPivotIds.filter(id => id !== pivotId)
+ : [...state.selectedPivotIds, pivotId]
+ };
+ }),
+
+ clearPivotSelection: () => set({ selectedPivotIds: [] }),
+
+ moveBuilderPivot: (pivotId: string, x: number, y: number) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ // Find and update pivot position
+ const movePivot = (pivot: Pivot): boolean => {
+ if (pivot.id === pivotId) {
+ pivot.x = x;
+ pivot.y = y;
+ return true;
+ }
+ for (const child of pivot.children) {
+ if (movePivot(child)) return true;
+ }
+ return false;
+ };
+
+ movePivot(figure.root_pivot);
+ return { builderFigure: figure };
+ }),
+
+ setBuilderShapeType: (type) => set({ builderShapeType: type }),
+
+ addBuilderShape: () => set((state) => {
+ if (!state.builderFigure || state.selectedPivotIds.length < 2) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ const newShape: Shape = {
+ type: state.builderShapeType,
+ pivotIds: [...state.selectedPivotIds]
+ };
+
+ figure.shapes.push(newShape);
+
+ return {
+ builderFigure: figure,
+ selectedPivotIds: []
+ };
+ }),
+
+ removeBuilderShape: (shapeIndex) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+ figure.shapes.splice(shapeIndex, 1);
+
+ return { builderFigure: figure };
+ }),
+
+ setBuilderPivotType: (pivotId, type) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ const updatePivotType = (pivot: Pivot): boolean => {
+ if (pivot.id === pivotId) {
+ pivot.type = type;
+ return true;
+ }
+ for (const child of pivot.children) {
+ if (updatePivotType(child)) return true;
+ }
+ return false;
+ };
+ updatePivotType(figure.root_pivot);
+
+ return { builderFigure: figure };
+ }),
+
+ setBuilderRootPivot: (pivotId) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+ const container = figure.root_pivot;
+
+ // Find pivot and its parent within the forest
+ let target: Pivot | null = null;
+ let parent: Pivot | null = null;
+ const walk = (p: Pivot, par: Pivot | null = null) => {
+ if (p.id === pivotId) {
+ target = p;
+ parent = par;
+ return;
+ }
+ p.children.forEach((child) => {
+ if (!target) walk(child, p);
+ });
+ };
+
+ container.children.forEach((rootChild) => {
+ if (!target) walk(rootChild, container);
+ });
+
+ if (!target || !parent) return state; // not found or already top-level
+
+ // Detach target from its parent
+ const parentPivot: Pivot = parent;
+ parentPivot.children = parentPivot.children.filter((c) => c.id !== pivotId);
+
+ // Add target as a new top-level root under the container
+ if (container.children.every((c) => c.id !== target!.id)) {
+ container.children.push(target);
+ }
+
+ return { builderFigure: figure };
+ }),
+
+ validateBuilderFigure: () => set((state) => {
+ if (!state.builderFigure) return { validationErrors: [] };
+
+ const errors: ValidationError[] = [];
+ const figure = state.builderFigure;
+
+ // Collect all pivot IDs
+ const allPivotIds = new Set();
+ const collectPivots = (pivot: Pivot) => {
+ allPivotIds.add(pivot.id);
+ pivot.children.forEach(collectPivots);
+ };
+ collectPivots(figure.root_pivot);
+
+ // Check for isolated pivots (not in any shape)
+ const pivotsInShapes = new Set();
+ figure.shapes.forEach(shape => {
+ shape.pivotIds.forEach(id => pivotsInShapes.add(id));
+ });
+
+ allPivotIds.forEach(pivotId => {
+ if (!pivotsInShapes.has(pivotId) && pivotId !== figure.root_pivot.id) {
+ errors.push({
+ type: 'isolated-pivot',
+ pivotId,
+ message: `Pivot ${pivotId} is not connected to any shape`
+ });
+ }
+ });
+
+ // Check for triangles with joint pivots (invalid rigid structure)
+ figure.shapes.forEach(shape => {
+ if (shape.type === 'polygon' && shape.pivotIds.length === 3) {
+ const findPivotType = (id: string): 'joint' | 'fixed' | null => {
+ let foundType: 'joint' | 'fixed' | null = null;
+ const search = (pivot: Pivot): void => {
+ if (pivot.id === id) {
+ foundType = pivot.type;
+ return;
+ }
+ pivot.children.forEach(search);
+ };
+ search(figure.root_pivot);
+ return foundType;
+ };
+
+ const hasJoint = shape.pivotIds.some(id => findPivotType(id) === 'joint');
+ if (hasJoint) {
+ errors.push({
+ type: 'invalid-joint',
+ message: `Triangle shape cannot have joint pivots (must be all fixed for rigid structure)`
+ });
+ }
+ }
+ });
+
+ return { validationErrors: errors };
+ }),
+
+ saveBuilderFigure: (name) => set((state) => {
+ if (!state.builderFigure) return state;
+
+ const customFigures = JSON.parse(localStorage.getItem('customFigures') || '{}');
+ const figureId = `custom-${Date.now()}`;
+
+ customFigures[figureId] = {
+ id: figureId,
+ name,
+ figure: state.builderFigure,
+ createdAt: Date.now()
+ };
+
+ localStorage.setItem('customFigures', JSON.stringify(customFigures));
+
+ return state;
+ }),
+
+ loadBuilderFigure: (figureId) => set(() => {
+ const customFigures = JSON.parse(localStorage.getItem('customFigures') || '{}');
+
+ if (customFigures[figureId]) {
+ return {
+ builderFigure: JSON.parse(JSON.stringify(customFigures[figureId].figure)),
+ selectedPivotIds: [],
+ validationErrors: []
+ };
+ }
+
+ return {};
+ }),
+
+ exportBuilderFigure: (name) => {
+ const state = get();
+ if (!state.builderFigure) return;
+
+ const dataStr = "data:text/json;charset=utf-8," +
+ encodeURIComponent(JSON.stringify(state.builderFigure, null, 2));
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.setAttribute("href", dataStr);
+ downloadAnchorNode.setAttribute("download", `${name}.psfigure`);
+ document.body.appendChild(downloadAnchorNode);
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+ },
+
+ resetBuilderFigure: () => set({
+ builderFigure: null,
+ selectedPivotIds: [],
+ validationErrors: [],
+ builderRootPivotId: null,
+ connectingPivots: []
+ }),
+
+ addConnectingPivot: (pivotId) => set((state) => ({
+ connectingPivots: [...state.connectingPivots, pivotId]
+ })),
+
+ clearConnectingPivots: () => set({ connectingPivots: [] }),
+
+ createLineFromConnecting: () => set((state) => {
+ if (!state.builderFigure || state.connectingPivots.length < 2) return state;
+
+ const figure = JSON.parse(JSON.stringify(state.builderFigure)) as Figure;
+
+ // Create a line shape connecting the two pivots
+ const newShape: Shape = {
+ type: 'line',
+ pivotIds: [state.connectingPivots[0], state.connectingPivots[1]]
+ };
+
+ figure.shapes.push(newShape);
+
+ return {
+ builderFigure: figure,
+ connectingPivots: [] // Clear after creating
+ };
+ }),
}));
diff --git a/electron/main.ts b/electron/main.ts
index 2a05f8a..8485a2a 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -1,76 +1,227 @@
-import { app, BrowserWindow, ipcMain } from 'electron';
-import path from 'path';
-import { autoUpdater } from 'electron-updater';
+import { app, BrowserWindow, session, protocol, globalShortcut } from 'electron';
+import * as path from 'path';
+import * as fs from 'fs';
let mainWindow: BrowserWindow | null = null;
-const isDev = !app.isPackaged;
+
+const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
+
+// Register custom protocol for serving static files
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: 'app',
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ },
+ },
+]);
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
- height: 720,
+ height: 800,
+ minWidth: 800,
+ minHeight: 600,
webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
- nodeIntegration: true,
- contextIsolation: false, // For now, or follow security best practices
},
+ titleBarStyle: 'hiddenInset',
+ show: false,
});
- const url = isDev
- ? 'http://localhost:3000'
- : `file://${path.join(__dirname, '../out/index.html')}`;
-
- mainWindow.loadURL(url);
+ // Set COOP/COEP headers for SharedArrayBuffer (required for ffmpeg.wasm)
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+ callback({
+ responseHeaders: {
+ ...details.responseHeaders,
+ 'Cross-Origin-Opener-Policy': ['same-origin'],
+ 'Cross-Origin-Embedder-Policy': ['require-corp'],
+ },
+ });
+ });
if (isDev) {
+ // In development, load from Next.js dev server
+ mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
+ } else {
+ // In production, use custom protocol
+ mainWindow.loadURL('app://./index.html');
}
- mainWindow.on('closed', () => {
- mainWindow = null;
- });
-
- // Auto-updater events
- autoUpdater.on('update-available', () => {
- mainWindow?.webContents.send('update-available');
+ // Register keyboard shortcuts for DevTools
+ mainWindow.webContents.on('before-input-event', (event, input) => {
+ // F12 to toggle DevTools
+ if (input.key === 'F12') {
+ if (mainWindow?.webContents.isDevToolsOpened()) {
+ mainWindow.webContents.closeDevTools();
+ } else {
+ mainWindow?.webContents.openDevTools();
+ }
+ }
+ // Ctrl+Shift+I (Windows/Linux) or Cmd+Option+I (Mac) to toggle DevTools
+ if ((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'i') {
+ event.preventDefault();
+ if (mainWindow?.webContents.isDevToolsOpened()) {
+ mainWindow.webContents.closeDevTools();
+ } else {
+ mainWindow?.webContents.openDevTools();
+ }
+ }
});
- autoUpdater.on('update-downloaded', () => {
- mainWindow?.webContents.send('update-downloaded');
+ mainWindow.once('ready-to-show', () => {
+ mainWindow?.show();
});
- autoUpdater.on('error', (err) => {
- console.error('Auto-updater error:', err);
- });
-
- // Check for updates once window is ready and not in dev
- mainWindow.once('ready-to-show', () => {
- mainWindow?.show();
- if (!isDev) {
- autoUpdater.checkForUpdatesAndNotify();
- }
+ mainWindow.on('closed', () => {
+ mainWindow = null;
});
}
-// IPC handlers for manual check and install
-ipcMain.handle('check-for-updates', async () => {
- if (isDev) {
- console.log('Skipping update check in dev mode');
- return null;
+app.whenReady().then(() => {
+ // Register global shortcuts for DevTools (works even when window is not focused)
+ globalShortcut.register('F12', () => {
+ if (mainWindow) {
+ if (mainWindow.webContents.isDevToolsOpened()) {
+ mainWindow.webContents.closeDevTools();
+ } else {
+ mainWindow.webContents.openDevTools();
+ }
}
- return autoUpdater.checkForUpdates();
-});
+ });
-ipcMain.handle('quit-and-install', () => {
- autoUpdater.quitAndInstall();
-});
+ // Register custom protocol handler for production
+ if (!isDev) {
+ protocol.handle('app', async (request) => {
+ const url = new URL(request.url);
+ let filePath = url.pathname;
+
+ console.log('[Protocol] Request URL:', request.url);
+ console.log('[Protocol] Initial filePath:', filePath);
+
+ // Remove leading slash on Windows
+ if (filePath.startsWith('/')) {
+ filePath = filePath.slice(1);
+ }
+
+ // Remove './' prefix if present
+ if (filePath.startsWith('./')) {
+ filePath = filePath.slice(2);
+ }
+
+ // Handle root path
+ if (filePath === '' || filePath === '.') {
+ filePath = 'index.html';
+ }
+
+ // Handle trailing slash for directory navigation (Next.js trailingSlash: true)
+ // Remove trailing slash for path processing, we'll add index.html if it's a directory
+ const hasTrailingSlash = filePath.endsWith('/');
+ if (hasTrailingSlash && filePath !== '/') {
+ filePath = filePath.slice(0, -1);
+ }
+
+ // Construct full path to the file in the out directory
+ const outDir = path.join(__dirname, '..', 'out');
+ let fullPath = path.join(outDir, filePath);
+
+ console.log('[Protocol] After normalization filePath:', filePath);
+ console.log('[Protocol] Initial fullPath:', fullPath);
+
+ // Check if path exists
+ if (fs.existsSync(fullPath)) {
+ const stat = fs.statSync(fullPath);
+ if (stat.isDirectory()) {
+ // If it's a directory, look for index.html inside
+ fullPath = path.join(fullPath, 'index.html');
+ }
+ } else {
+ // File doesn't exist, try alternatives
+ // 1. Try with .html extension
+ if (!filePath.includes('.') && fs.existsSync(`${fullPath}.html`)) {
+ fullPath = `${fullPath}.html`;
+ }
+ // 2. Try index.html in subdirectory (for trailing slash routes)
+ else if (fs.existsSync(path.join(fullPath, 'index.html'))) {
+ fullPath = path.join(fullPath, 'index.html');
+ }
+ // 3. Fallback to 404
+ else if (!fs.existsSync(fullPath)) {
+ fullPath = path.join(outDir, '404', 'index.html');
+ if (!fs.existsSync(fullPath)) {
+ fullPath = path.join(outDir, '404.html');
+ }
+ }
+ }
+
+ console.log('[Protocol] Final fullPath:', fullPath);
+ console.log('[Protocol] File exists:', fs.existsSync(fullPath));
+
+ // Read file and create response with COOP/COEP headers
+ try {
+ if (!fs.existsSync(fullPath)) {
+ console.error('[Protocol] File not found:', fullPath);
+ return new Response('Not Found', {
+ status: 404,
+ headers: {
+ 'Content-Type': 'text/plain',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ },
+ });
+ }
+
+ const fileBuffer = fs.readFileSync(fullPath);
+ const ext = path.extname(fullPath).toLowerCase();
+
+ // Determine MIME type
+ const mimeTypes: Record = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.wasm': 'application/wasm',
+ };
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
+
+ return new Response(fileBuffer, {
+ status: 200,
+ headers: {
+ 'Content-Type': mimeType,
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ },
+ });
+ } catch (error) {
+ console.error('[Protocol] Error reading file:', error);
+ return new Response('Internal Server Error', {
+ status: 500,
+ headers: {
+ 'Content-Type': 'text/plain',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ },
+ });
+ }
+ });
+ }
-app.whenReady().then(() => {
createWindow();
-
- app.on('activate', () => {
- if (mainWindow === null) createWindow();
- });
});
app.on('window-all-closed', () => {
@@ -78,3 +229,14 @@ app.on('window-all-closed', () => {
app.quit();
}
});
+
+app.on('activate', () => {
+ if (mainWindow === null) {
+ createWindow();
+ }
+});
+
+// Unregister all shortcuts when app quits
+app.on('will-quit', () => {
+ globalShortcut.unregisterAll();
+});
diff --git a/electron/preload.ts b/electron/preload.ts
index fcf556f..01b19bc 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -1,7 +1,7 @@
// Preload script for Electron
// This runs in renderer context with access to Node.js APIs
-import { contextBridge } from 'electron';
+import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods to the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
@@ -14,3 +14,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
});
+// Expose IPC methods for update checking and installation
+contextBridge.exposeInMainWorld('electron', {
+ ipcRenderer: {
+ on: (channel: string, callback: (...args: any[]) => void) => {
+ ipcRenderer.on(channel, (event, ...args) => callback(...args));
+ },
+ invoke: (channel: string, ...args: any[]) => {
+ return ipcRenderer.invoke(channel, ...args);
+ },
+ },
+});