1. Repository
2. Overview
The application uses Tauri's WebviewWindow API to create separate native windows. Each window runs its own webview with shared Redux state, allowing for synchronized settings like dark mode.
3. Step-by-Step Guide
3.1. Steps
3.1.1. Create Your Custom Component
Create your component in the appropriate directory (e.g., src/app-component/):
// src/app-component/MyCustomComponent.tsx export default function MyCustomComponent({ someId }: { someId?: number }) { return ( <div className="h-screen w-screen bg-white dark:bg-gray-900"> <h1>My Custom Window</h1> {/* Your component content */} </div> ); }
Important: Use Tailwind's dark: prefix for dark mode styling.
3.1.2. Create Window Entry Point
Create an entry file in src/subwindows/:
// src/subwindows/my-window-entry.tsx import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { store } from "../store/store"; import MyCustomComponent from "../app-component/MyCustomComponent"; import { BackendLoadingScreen } from "../components/BackendLoadingScreen"; import { appStateApi } from "../store/api/appStateApi"; import { useAppSelector } from "../store/hooks"; import "../index.css"; function MyWindowContent() { const [someId, setSomeId] = useState<number | undefined>(undefined); const backendPort = useAppSelector((s) => s.config.backendPort); // Fetch app state to get dark mode setting const { data: appState } = appStateApi.endpoints.getAppState.useQuery(undefined, { skip: !backendPort, }); const darkMode = appState?.darkMode ?? false; useEffect(() => { // Get parameters from URL query string const params = new URLSearchParams(window.location.search); const id = params.get("someId"); if (id) { setSomeId(parseInt(id, 10)); } }, []); // Apply dark mode class to html element useEffect(() => { if (darkMode) { document.documentElement.classList.add("dark"); } else { document.documentElement.classList.remove("dark"); } }, [darkMode]); return <MyCustomComponent someId={someId} />; } ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <React.StrictMode> <Provider store={store}> <BackendLoadingScreen> <MyWindowContent /> </BackendLoadingScreen> </Provider> </React.StrictMode> );
Key Points:
- Wrap with
Providerand pass the samestoreinstance to share Redux state - Use
BackendLoadingScreento ensure backend is ready - Fetch
appStateusing RTK Query to get dark mode setting - Apply
darkclass todocument.documentElementbased on dark mode - Extract URL parameters to pass data to your component
3.1.3. Create HTML Entry File
Create an HTML file in the project root:
<!-- my-window.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>My Custom Window</title> </head> <body> <div id="root"></div> <script type="module" src="/src/subwindows/my-window-entry.tsx"></script> </body> </html>
3.1.4. Update Vite Configuration
Add your new window to the Vite build configuration:
// vite.config.ts export default defineConfig({ // ... other config build: { rollupOptions: { input: { main: "index.html", markdown: "markdown-window.html", mywindow: "my-window.html", // Add your window here }, }, }, });
3.1.5. Update Tauri Permissions
Add window creation permissions to src-tauri/tauri.conf.json:
{ "app": { "security": { "capabilities": [ { "identifier": "main-window", "windows": ["main"], "permissions": [ "core:webview:allow-create-webview-window", "core:window:allow-get-all-windows", "core:window:allow-set-focus" ] }, { "identifier": "my-windows", "windows": ["my-*"], "permissions": ["core:window:default", "core:webview:default"] } ] } } }
Required permissions for main window:
core:webview:allow-create-webview-window- Create new child windowscore:window:allow-get-all-windows- Get list of all windows (for auto-focus feature)core:window:allow-set-focus- Bring subwindows to front when main window gains focus
3.1.6. Open the Window from Your Code
Use Tauri's WebviewWindow API to open your new window:
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; const handleOpenMyWindow = async (id: number) => { try { const windowLabel = `my-${id}`; const url = `/my-window.html?someId=${id}`; const newWindow = new WebviewWindow(windowLabel, { url, title: "My Custom Window", width: 1200, height: 800, // parent: "main", // Optional - see "Window Parent Relationship" section below }); // Optional: Listen for events await newWindow.once("tauri://error", (e) => { console.error("Error creating window:", e); }); } catch (error) { console.error("Failed to open window:", error); } };
3.2. Passing State via Query Parameters
You can pass additional state to child windows via URL query parameters to initialize the component's state.
Example: Edit Mode Parameter
// src/lib/subwindowPaths.ts export const getSubwindowPaths = { markdown: (scriptId: number, editMode: boolean = false): string => { const path = "src/subwindows/markdown-window.html"; const queryParams = `scriptId=${scriptId}${editMode ? "&editMode=true" : ""}`; const url = import.meta.env.DEV ? `http://localhost:1420/${path}?${queryParams}` : `/${path}?${queryParams}`; return url; }, };
Opening windows with different initial states:
// Open in view mode (default) const handleViewClick = () => { const url = getSubwindowPaths.markdown(scriptId, false); new WebviewWindow(`markdown-${scriptId}`, { url, title: `View: ${scriptName}`, // ... other options }); }; // Open in edit mode const handleEditClick = () => { const url = getSubwindowPaths.markdown(scriptId, true); new WebviewWindow(`markdown-${scriptId}`, { url, title: `Edit: ${scriptName}`, // ... other options }); };
Reading query parameters in the child window component:
export default function MyComponent({ someId }: { someId: number | undefined }) { // Read query parameter from URL const urlParams = new URLSearchParams(window.location.search); const editModeFromUrl = urlParams.get("editMode") === "true"; // Initialize state with the query parameter value const [isEditMode, setIsEditMode] = useState(editModeFromUrl); // Optional: Initialize other state when starting in special mode useEffect(() => { if (editModeFromUrl && data) { // Perform additional initialization for edit mode setSomeState(data.someValue); } }, [data, editModeFromUrl]); return <div>{isEditMode ? <EditView /> : <ReadOnlyView />}</div>; }
Benefits:
- Different entry points can open the same window with different initial configurations
- Avoids the need to pass state through events after window creation
- Cleaner URL-based state initialization
- Supports browser-like navigation patterns
3.3. Window Parent Relationship
The parent: "main" property is optional and has trade-offs:
With parent: "main":
- ✅ Child window automatically stays above parent window (z-order)
- ✅ OS window managers may group windows together
- ❌ Child window moves when you drag the parent window (coupled movement)
- ❌ Minimizing parent may also minimize children (OS-dependent)
Without parent: "main" (independent windows):
- ✅ Windows move independently - drag main window without moving child
- ✅ More flexible window management
- ❌ Child windows don't automatically stay above parent
- ❌ May feel less "attached" to the main app
Important: The parent property is NOT required to prevent child windows from closing the main app. This is handled by the Rust code checking window labels (see "App Shutdown vs Child Window Close" section).
4. Dark Mode Synchronization
Dark mode synchronization happens automatically through:
- Shared Redux Store: Both windows use the same store instance
- RTK Query: Fetches
appStatewhich includesdarkModesetting - Class Application: Applies/removes
darkclass ondocument.documentElement
// This pattern syncs dark mode const { data: appState } = appStateApi.endpoints.getAppState.useQuery(undefined, { skip: !backendPort, }); const darkMode = appState?.darkMode ?? false; useEffect(() => { if (darkMode) { document.documentElement.classList.add("dark"); } else { document.documentElement.classList.remove("dark"); } }, [darkMode]);
When dark mode changes in the main window, RTK Query automatically updates all subscribed components across all windows, triggering the class update.
5. Inter-Window Communication
Important: Each Tauri window runs in its own JavaScript context with its own Redux store instance. While both windows import the same store file, they each create separate instances in their own memory space - they do NOT share the same store.
When a child window makes changes that should be reflected in the main window (or other windows), you must use Tauri's event system to communicate between windows.
5.1. Why Event-Based Communication is Needed
Main Window Memory Space Child Window Memory Space ├── Redux Store (Instance A) ├── Redux Store (Instance B) ├── RTK Query Cache ├── RTK Query Cache └── React Components └── React Components ❌ Changes in Instance B do NOT automatically update Instance A ✅ Use emit/listen to notify other windows of changes
5.2. Implementation Pattern
In the child window (when making changes):
import { emit } from "@tauri-apps/api/event"; import { scriptApi } from "@/store/api/scriptApi"; const [updateData] = scriptApi.endpoints.updateData.useMutation(); const handleSave = async () => { // Save changes await updateData(updatedData).unwrap(); // Invalidate cache in this window dispatch(scriptApi.util.invalidateTags([{ type: "Script", id: dataId }])); // Notify other windows to refresh their cache await emit("data-updated", { id: dataId }); };
In the main window (listening for changes):
import { listen } from "@tauri-apps/api/event"; import { scriptApi } from "@/store/api/scriptApi"; useEffect(() => { const unlisten = listen<{ id: number }>("data-updated", ({ payload }) => { console.log("Data updated in child window, refreshing cache"); dispatch(scriptApi.util.invalidateTags([{ type: "Script", id: payload.id }])); }); return () => { unlisten.then((fn) => fn()); }; }, [dispatch]);
Key Points:
- Emit events from the window making changes to notify others
- Listen for events in windows that need to stay synchronized
- Invalidate RTK Query cache in both the sending and receiving windows
- Use specific event names like
"markdown-updated","folder-created", etc. - Include necessary data in the event payload (IDs, etc.)
Example: Markdown Editor
The markdown editor demonstrates this pattern:
- Child window edits markdown name/content
- After saving, emits
"markdown-updated"event with script ID - Main window listens for event and invalidates its cache
- Main window UI automatically refreshes with updated data
This pattern ensures all windows stay in sync despite having separate Redux store instances.
6. Example: Markdown Window
See the existing implementation:
- Entry: src/subwindows/markdown-window-entry.tsx
- HTML: markdown-window.html
- Component: src/app-component/ScriptsColumn/MarkdownEditor.tsx
- Opener: src/app-component/ScriptsColumn/MarkdownItem.tsx
7. Window Lifecycle & Closing Behavior
7.1. Closing Child Windows
Child windows can be closed normally using getCurrentWindow().close():
import { getCurrentWindow } from "@tauri-apps/api/window"; const handleCloseWindow = () => { getCurrentWindow().close(); };
7.2. Rust Adjustment for App Shutdown vs Child Window Close
The application distinguishes between closing the main window and closing child windows:
- Main Window Close: Triggers backend shutdown, shows "Shutting down..." overlay, and cleans up resources
- Child Window Close: Closes immediately without affecting the main application
This is handled in src-tauri/src/lib.rs:
.on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Only handle close for the main window if window.label() != "main" { // Allow child windows to close normally return; } // Main window cleanup logic... } })
Key Points:
- The
on_window_eventhandler filters events by window label - Only the main window close (label = "main") triggers the
"app-closing"event - Child windows close immediately without cleanup delays
- The
parent: "main"property is NOT required for this safety mechanism
7.3. Closing Overlay
The AppClosingOverlay component shows a "Shutting down..." message only when the main window is closing:
// src/components/AppClosingOverlay.tsx // Listens to "app-closing" event, which is only emitted for main window
Because the Rust handler filters by window label (window.label() != "main"), closing child windows will NOT trigger this overlay, regardless of whether they have a parent property set.
8. Window Z-Order Management
The application uses natural OS window management for z-ordering:
Default Behavior:
- Click any window → That window comes to the front
- Windows stack based on which one you clicked most recently
- Each window (main or child) can be independently brought to the top
- No automatic stacking or forced ordering between windows
Why This Approach:
- ✅ Natural, predictable window behavior
- ✅ Users can organize windows as they prefer
- ✅ No flickering or forced focus changes
- ✅ Works seamlessly with OS window management (Mission Control, Spaces, etc.)
Alternative: Auto-Focus (Not Recommended):
You could implement auto-focus to bring all subwindows forward when clicking the main window:
// ⚠️ Not recommended - can cause flickering and unexpected behavior useEffect(() => { const mainWindow = getCurrentWindow(); const unlisten = mainWindow.onFocusChanged(async ({ payload: focused }) => { if (focused) { const allWindows = await getAllWebviewWindows(); const subwindows = allWindows.filter((w) => w.label !== "main"); for (const subwindow of subwindows) { await subwindow.setFocus(); } } }); return () => { unlisten.then((fn) => fn()); }; }, []);
Issues with Auto-Focus:
- ❌ Causes flickering as windows are programmatically focused
- ❌ Prevents natural window ordering based on user clicks
- ❌ Main window may end up behind subwindows
- ❌ Interferes with multi-window workflows
Best Practice: Let the OS handle window z-ordering naturally. Users can click whichever window they want to bring to front.
9. Troubleshooting
9.1. Window Won't Open
- Check Tauri permissions in
tauri.conf.json - Verify the window label pattern matches the capability pattern
- Check browser console for permission errors
9.2. Closing Child Window Triggers App Shutdown
- Check that
src-tauri/src/lib.rsfilters events bywindow.label() != "main" - Ensure the child window label is NOT "main" (use patterns like
my-*,markdown-*, etc.) - Verify window label follows the pattern defined in capabilities
9.3. Child Window Moves When Dragging Main Window
- This happens when
parent: "main"is set inWebviewWindowoptions - Remove the
parentproperty for independent window movement - Note: Removing
parentis safe - the Rust close handler checks window labels, not parent relationships
9.4. Dark Mode Not Syncing
- Ensure Redux Provider wraps your component
- Verify
appStateApiquery is active (not skipped) - Check that
darkclass is applied todocument.documentElement, not body
9.5. Build Fails
- Ensure HTML file is added to
vite.config.tsrollupOptions.input - Verify all import paths are correct relative to the entry file location
10. Content Security Policy (CSP)
Tauri applications run in a secure webview environment that enforces a Content Security Policy (CSP) to prevent security vulnerabilities. When creating new windows or adding features that interact with external resources, you may need to adjust the CSP.
10.1. When to Configure CSP
You need to modify the CSP when:
- Backend Communication: Your frontend needs to fetch data from a local backend server (e.g., Spring Boot on
localhost) - External API Calls: Your app makes requests to external APIs (e.g., OpenAI, weather APIs)
- Dynamic Styles: Using CSS-in-JS libraries that inject styles dynamically (e.g., MUI with Emotion, styled-components)
- WebSockets: Establishing WebSocket connections to local or remote servers
- Images from External Sources: Loading images from HTTPS URLs or data URIs
- Web Workers: If your dependencies use web workers (though this is rare in typical React apps)
10.2. CSP Configuration Location
Edit src-tauri/tauri.conf.json:
{ "app": { "security": { "csp": "default-src 'self' tauri: http://tauri.localhost; ..." } } }
10.3. Common CSP Directives
Backend Communication (Critical):
"csp": "... connect-src 'self' tauri: http://tauri.localhost http://localhost:* ws://localhost:*; ..."
http://localhost:*- Allowsfetch()requests to your local backend on any portws://localhost:*- Allows WebSocket connections to local backend- Why needed: Tauri runs on
http://tauri.localhost, but your backend runs onhttp://localhost:port. These are different origins, so CORS applies.
External API Calls:
"csp": "... connect-src 'self' tauri: http://tauri.localhost https://api.example.com; ..."
- Add specific HTTPS domains for each external API you call
- Example:
https://api.openai.comfor OpenAI API calls
Dynamic Styles (MUI/Emotion):
"csp": "... style-src 'self' tauri: http://tauri.localhost 'unsafe-inline'; ..."
'unsafe-inline'- Required for libraries that inject<style>tags dynamically- Needed for: MUI (Emotion), styled-components, CSS-in-JS libraries
External Images:
"csp": "... img-src 'self' tauri: http://tauri.localhost data: https:; ..."
data:- Allows inline base64-encoded imageshttps:- Allows images from any HTTPS source (more permissive, use specific domains for stricter security)
10.4. Complete Example
Here's a typical CSP for an app with a local backend and MUI:
"security": { "csp": "default-src 'self' tauri: http://tauri.localhost; connect-src 'self' tauri: http://tauri.localhost http://localhost:* ws://localhost:*; font-src 'self' tauri: http://tauri.localhost; img-src 'self' tauri: http://tauri.localhost data: https:; style-src 'self' tauri: http://tauri.localhost 'unsafe-inline'; script-src 'self' tauri: http://tauri.localhost 'unsafe-inline' 'unsafe-eval'" }
Breakdown:
default-src 'self' tauri: http://tauri.localhost- Base policy for all resourcesconnect-src ... http://localhost:*- Backend API calls + WebSocketsfont-src 'self' tauri: http://tauri.localhost- Local fonts onlyimg-src ... data: https:- Local images + base64 + HTTPS imagesstyle-src ... 'unsafe-inline'- Dynamic styles for MUI/Emotionscript-src ... 'unsafe-eval'- Some frameworks need this (use cautiously)
10.5. Debugging CSP Issues
Symptoms:
fetch()calls fail with CORS errors- Styles don't apply or look broken
- Console shows CSP violation errors:
"Refused to load ... because it violates the following Content Security Policy directive..."
Debugging Steps:
- Check Browser Console: Look for CSP violation messages
- Identify the Blocked Resource: Note which directive is violated (e.g.,
connect-src,style-src) - Add to CSP: Update the relevant directive in
tauri.conf.json - Rebuild: CSP changes require a full rebuild (
yarn bundle) - Test: Verify the resource now loads correctly
Example Error:
Refused to connect to 'http://localhost:8080/api/data' because it violates
the following Content Security Policy directive: "connect-src 'self' tauri: http://tauri.localhost".
Solution: Add http://localhost:* to connect-src.
10.6. Security Best Practices
- Be Specific: Instead of
https:, list specific domains:https://api.example.com https://cdn.example.com - Avoid
unsafe-eval: Only use if absolutely necessary (some frameworks require it) - Minimize
unsafe-inline: Only add forstyle-srcif using CSS-in-JS libraries - Test Thoroughly: Test all features after CSP changes to ensure nothing is blocked
- Development vs Production: CSP applies to both - test in production builds
10.7. Common Pitfall: Backend Port Changes
If your backend port changes dynamically, http://localhost:* (with wildcard) is the safest approach. Alternatively, use a fixed port in your backend configuration.











