1633 lines
44 KiB
Markdown
1633 lines
44 KiB
Markdown
# ToDesktop Implementation Guide for Plane Desktop Application
|
|
|
|
This guide provides a comprehensive walkthrough for converting the Plane web application into a cross-platform desktop application using [ToDesktop Builder](https://www.todesktop.com/).
|
|
|
|
## Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [Prerequisites](#prerequisites)
|
|
3. [Getting Started with ToDesktop Builder](#getting-started-with-todesktop-builder)
|
|
4. [Self-Hosted Support](#self-hosted-support)
|
|
5. [Configuration Options](#configuration-options)
|
|
6. [Implementing Tabs](#implementing-tabs)
|
|
7. [Multi-Window Support](#multi-window-support)
|
|
8. [Inter-Window Communication](#inter-window-communication)
|
|
9. [Browser Controls & Navigation](#browser-controls--navigation)
|
|
10. [Native API Integration](#native-api-integration)
|
|
11. [CLI Setup for Automated Builds](#cli-setup-for-automated-builds)
|
|
12. [Best Practices](#best-practices)
|
|
13. [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
ToDesktop Builder converts web applications into native desktop apps for macOS, Windows, and Linux. It uses Electron under the hood but abstracts away most of the complexity, allowing you to create desktop apps without extensive Electron knowledge.
|
|
|
|
### Key Benefits
|
|
|
|
- **No-code builder interface** for rapid prototyping
|
|
- **Native functionality** via JavaScript APIs
|
|
- **Auto-updates** built-in
|
|
- **Code signing** handled automatically
|
|
- **Cross-platform** support (Mac, Windows, Linux)
|
|
- **Plugin ecosystem** for extended functionality
|
|
|
|
---
|
|
|
|
## Prerequisites
|
|
|
|
- Node.js 22+ (matches Plane requirements)
|
|
- ToDesktop Builder application installed on your machine
|
|
- A ToDesktop account (free tier available for local development)
|
|
- Plane web application running locally or deployed
|
|
|
|
---
|
|
|
|
## Getting Started with ToDesktop Builder
|
|
|
|
### Step 1: Install ToDesktop Builder
|
|
|
|
1. Download the installer from [todesktop.com](https://www.todesktop.com/)
|
|
2. Install and launch the application
|
|
3. Create an account through the browser-based signup flow
|
|
4. Complete the onboarding wizard
|
|
|
|
### Step 2: Create Your First Desktop App
|
|
|
|
1. In ToDesktop Builder, click **"Create New App"**
|
|
2. Select **"Simple Window"** for the initial setup
|
|
3. Enter your Plane web app URL:
|
|
- **Local development**: `http://localhost:3000`
|
|
- **Production**: Your deployed Plane instance URL
|
|
4. Configure basic app settings:
|
|
- **App Name**: `Plane`
|
|
- **App Icon**: Use the Plane logo
|
|
- **Window Dimensions**: Recommended `1280x800` minimum
|
|
|
|
### Step 3: Initial Configuration
|
|
|
|
In the ToDesktop Builder UI, configure the following sections:
|
|
|
|
#### Application Settings
|
|
|
|
| Setting | Recommended Value |
|
|
| ----------------- | -------------------------- |
|
|
| App Title | Plane |
|
|
| Launch on Startup | Optional (user preference) |
|
|
| Offline Support | Enabled |
|
|
| Window Frame | Native |
|
|
|
|
#### Window Configuration
|
|
|
|
| Setting | Value |
|
|
| -------------- | ------------------------------------ |
|
|
| Width | 1280 |
|
|
| Height | 800 |
|
|
| Min Width | 800 |
|
|
| Min Height | 600 |
|
|
| Resizable | Yes |
|
|
| Titlebar Style | Default (or `hiddenInset` for macOS) |
|
|
|
|
---
|
|
|
|
## Self-Hosted Support
|
|
|
|
Plane supports both cloud-hosted (`app.plane.so`) and self-hosted deployments. On first launch, the desktop application prompts users to select their deployment type and configure the appropriate endpoint.
|
|
|
|
### Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ First Launch Flow │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────┐ │
|
|
│ │ Setup Screen │───▶│ Cloud Selected │───▶│ app.plane │ │
|
|
│ │ │ │ │ │ .so │ │
|
|
│ │ Cloud or │ └─────────────────┘ └────────────┘ │
|
|
│ │ Self-hosted? │ │
|
|
│ │ │ ┌─────────────────┐ ┌────────────┐ │
|
|
│ │ │───▶│ Self-hosted │───▶│ Custom URL │ │
|
|
│ └──────────────┘ │ URL Input │ │ │ │
|
|
│ └─────────────────┘ └────────────┘ │
|
|
│ │
|
|
│ Configuration stored in electron-store / localStorage │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Configuration Storage
|
|
|
|
Store the instance configuration using Electron's persistent storage:
|
|
|
|
```typescript
|
|
// src/lib/desktop/instance-config.ts
|
|
|
|
export interface InstanceConfig {
|
|
type: "cloud" | "self-hosted";
|
|
endpoint: string;
|
|
configuredAt: number;
|
|
}
|
|
|
|
const CLOUD_ENDPOINT = "https://app.plane.so";
|
|
const CONFIG_KEY = "plane-instance-config";
|
|
|
|
/**
|
|
* Get the stored instance configuration
|
|
*/
|
|
export function getInstanceConfig(): InstanceConfig | null {
|
|
if (typeof window === "undefined") return null;
|
|
|
|
const stored = localStorage.getItem(CONFIG_KEY);
|
|
if (!stored) return null;
|
|
|
|
try {
|
|
return JSON.parse(stored) as InstanceConfig;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save instance configuration
|
|
*/
|
|
export function saveInstanceConfig(config: InstanceConfig): void {
|
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
|
}
|
|
|
|
/**
|
|
* Clear instance configuration (for reset/logout)
|
|
*/
|
|
export function clearInstanceConfig(): void {
|
|
localStorage.removeItem(CONFIG_KEY);
|
|
}
|
|
|
|
/**
|
|
* Check if instance is configured
|
|
*/
|
|
export function isInstanceConfigured(): boolean {
|
|
return getInstanceConfig() !== null;
|
|
}
|
|
|
|
/**
|
|
* Get the API endpoint based on configuration
|
|
*/
|
|
export function getApiEndpoint(): string {
|
|
const config = getInstanceConfig();
|
|
if (!config) return CLOUD_ENDPOINT;
|
|
return config.endpoint;
|
|
}
|
|
|
|
/**
|
|
* Configure cloud instance
|
|
*/
|
|
export function configureCloudInstance(): InstanceConfig {
|
|
const config: InstanceConfig = {
|
|
type: "cloud",
|
|
endpoint: CLOUD_ENDPOINT,
|
|
configuredAt: Date.now(),
|
|
};
|
|
saveInstanceConfig(config);
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Configure self-hosted instance
|
|
*/
|
|
export function configureSelfHostedInstance(endpoint: string): InstanceConfig {
|
|
// Normalize the endpoint URL
|
|
let normalizedEndpoint = endpoint.trim();
|
|
if (!normalizedEndpoint.startsWith("http")) {
|
|
normalizedEndpoint = `https://${normalizedEndpoint}`;
|
|
}
|
|
// Remove trailing slash
|
|
normalizedEndpoint = normalizedEndpoint.replace(/\/$/, "");
|
|
|
|
const config: InstanceConfig = {
|
|
type: "self-hosted",
|
|
endpoint: normalizedEndpoint,
|
|
configuredAt: Date.now(),
|
|
};
|
|
saveInstanceConfig(config);
|
|
return config;
|
|
}
|
|
```
|
|
|
|
### Setup Screen Component
|
|
|
|
Create the first-launch setup screen:
|
|
|
|
```typescript
|
|
// src/components/desktop/InstanceSetup.tsx
|
|
import { useState } from "react";
|
|
import {
|
|
configureCloudInstance,
|
|
configureSelfHostedInstance,
|
|
validateInstanceEndpoint,
|
|
} from "@/lib/desktop/instance-config";
|
|
|
|
type SetupStep = "choose" | "self-hosted-input" | "validating" | "complete";
|
|
|
|
interface InstanceSetupProps {
|
|
onComplete: (endpoint: string) => void;
|
|
}
|
|
|
|
export function InstanceSetup({ onComplete }: InstanceSetupProps) {
|
|
const [step, setStep] = useState<SetupStep>("choose");
|
|
const [selfHostedUrl, setSelfHostedUrl] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
|
|
const handleCloudSelect = () => {
|
|
const config = configureCloudInstance();
|
|
onComplete(config.endpoint);
|
|
};
|
|
|
|
const handleSelfHostedSelect = () => {
|
|
setStep("self-hosted-input");
|
|
};
|
|
|
|
const handleSelfHostedSubmit = async () => {
|
|
if (!selfHostedUrl.trim()) {
|
|
setError("Please enter your Plane instance URL");
|
|
return;
|
|
}
|
|
|
|
setIsValidating(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Validate the endpoint
|
|
const isValid = await validateInstanceEndpoint(selfHostedUrl);
|
|
|
|
if (!isValid) {
|
|
setError("Unable to connect to Plane instance. Please check the URL and try again.");
|
|
setIsValidating(false);
|
|
return;
|
|
}
|
|
|
|
const config = configureSelfHostedInstance(selfHostedUrl);
|
|
onComplete(config.endpoint);
|
|
} catch (err) {
|
|
setError("Failed to validate instance. Please check your URL and network connection.");
|
|
setIsValidating(false);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
setStep("choose");
|
|
setError(null);
|
|
setSelfHostedUrl("");
|
|
};
|
|
|
|
// Choose deployment type screen
|
|
if (step === "choose") {
|
|
return (
|
|
<div className="flex h-screen w-screen items-center justify-center bg-custom-background-100">
|
|
<div className="w-full max-w-md space-y-8 p-8">
|
|
{/* Logo */}
|
|
<div className="flex flex-col items-center">
|
|
<img src="/plane-logo.svg" alt="Plane" className="h-12 w-12" />
|
|
<h1 className="mt-4 text-2xl font-semibold text-custom-text-100">Welcome to Plane</h1>
|
|
<p className="mt-2 text-center text-custom-text-300">Choose how you want to connect to Plane</p>
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div className="space-y-4">
|
|
{/* Cloud Option */}
|
|
<button
|
|
onClick={handleCloudSelect}
|
|
className="w-full rounded-lg border border-custom-border-200 p-4 text-left transition-colors hover:border-custom-primary-100 hover:bg-custom-background-80"
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className="rounded-lg bg-custom-primary-100/10 p-2">
|
|
<CloudIcon className="h-6 w-6 text-custom-primary-100" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-custom-text-100">Plane Cloud</h3>
|
|
<p className="mt-1 text-sm text-custom-text-300">Connect to app.plane.so - Managed by Plane</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Self-hosted Option */}
|
|
<button
|
|
onClick={handleSelfHostedSelect}
|
|
className="w-full rounded-lg border border-custom-border-200 p-4 text-left transition-colors hover:border-custom-primary-100 hover:bg-custom-background-80"
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className="rounded-lg bg-custom-primary-100/10 p-2">
|
|
<ServerIcon className="h-6 w-6 text-custom-primary-100" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-custom-text-100">Self-hosted</h3>
|
|
<p className="mt-1 text-sm text-custom-text-300">Connect to your own Plane instance</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Self-hosted URL input screen
|
|
if (step === "self-hosted-input") {
|
|
return (
|
|
<div className="flex h-screen w-screen items-center justify-center bg-custom-background-100">
|
|
<div className="w-full max-w-md space-y-6 p-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col items-center">
|
|
<img src="/plane-logo.svg" alt="Plane" className="h-12 w-12" />
|
|
<h1 className="mt-4 text-2xl font-semibold text-custom-text-100">Self-hosted Instance</h1>
|
|
<p className="mt-2 text-center text-custom-text-300">Enter the URL of your Plane instance</p>
|
|
</div>
|
|
|
|
{/* URL Input */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label htmlFor="instance-url" className="block text-sm font-medium text-custom-text-200">
|
|
Instance URL
|
|
</label>
|
|
<input
|
|
id="instance-url"
|
|
type="url"
|
|
value={selfHostedUrl}
|
|
onChange={(e) => setSelfHostedUrl(e.target.value)}
|
|
placeholder="https://plane.yourcompany.com"
|
|
className="mt-1 w-full rounded-md border border-custom-border-200 bg-custom-background-90 px-3 py-2 text-custom-text-100 placeholder:text-custom-text-400 focus:border-custom-primary-100 focus:outline-none focus:ring-1 focus:ring-custom-primary-100"
|
|
disabled={isValidating}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSelfHostedSubmit();
|
|
}}
|
|
/>
|
|
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleBack}
|
|
disabled={isValidating}
|
|
className="flex-1 rounded-md border border-custom-border-200 px-4 py-2 text-custom-text-200 transition-colors hover:bg-custom-background-80 disabled:opacity-50"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={handleSelfHostedSubmit}
|
|
disabled={isValidating || !selfHostedUrl.trim()}
|
|
className="flex-1 rounded-md bg-custom-primary-100 px-4 py-2 text-white transition-colors hover:bg-custom-primary-200 disabled:opacity-50"
|
|
>
|
|
{isValidating ? "Connecting..." : "Connect"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Help text */}
|
|
<p className="text-center text-xs text-custom-text-400">
|
|
Make sure your Plane instance is accessible from this device
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Icon components
|
|
function CloudIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ServerIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Endpoint Validation
|
|
|
|
Validate that the provided endpoint is a valid Plane instance:
|
|
|
|
```typescript
|
|
// src/lib/desktop/instance-config.ts (continued)
|
|
|
|
/**
|
|
* Validate that an endpoint is a valid Plane instance
|
|
*/
|
|
export async function validateInstanceEndpoint(endpoint: string): Promise<boolean> {
|
|
try {
|
|
// Normalize the endpoint
|
|
let url = endpoint.trim();
|
|
if (!url.startsWith("http")) {
|
|
url = `https://${url}`;
|
|
}
|
|
url = url.replace(/\/$/, "");
|
|
|
|
// Try to reach the Plane API health endpoint
|
|
const response = await fetch(`${url}/api/v1/`, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
// Short timeout for validation
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
|
|
// Check if response indicates a Plane instance
|
|
// Plane API typically returns a JSON response or redirects to auth
|
|
if (response.ok || response.status === 401 || response.status === 403) {
|
|
return true;
|
|
}
|
|
|
|
// Also try the instance config endpoint
|
|
const configResponse = await fetch(`${url}/api/v1/instance/configurations/`, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
|
|
return configResponse.ok || configResponse.status === 401 || configResponse.status === 403;
|
|
} catch (error) {
|
|
console.error("Endpoint validation failed:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the current instance is cloud-hosted
|
|
*/
|
|
export function isCloudInstance(): boolean {
|
|
const config = getInstanceConfig();
|
|
return config?.type === "cloud";
|
|
}
|
|
|
|
/**
|
|
* Check if the current instance is self-hosted
|
|
*/
|
|
export function isSelfHostedInstance(): boolean {
|
|
const config = getInstanceConfig();
|
|
return config?.type === "self-hosted";
|
|
}
|
|
```
|
|
|
|
### App Entry Point Integration
|
|
|
|
Integrate the setup flow into the app's entry point:
|
|
|
|
```typescript
|
|
// src/App.tsx or src/main.tsx
|
|
import { useEffect, useState } from "react";
|
|
import { InstanceSetup } from "@/components/desktop/InstanceSetup";
|
|
import { isInstanceConfigured, getApiEndpoint } from "@/lib/desktop/instance-config";
|
|
import { isDesktopApp } from "@/lib/desktop/utils";
|
|
|
|
function App() {
|
|
const [isConfigured, setIsConfigured] = useState<boolean | null>(null);
|
|
const [endpoint, setEndpoint] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Check if we're in a desktop environment
|
|
if (isDesktopApp()) {
|
|
const configured = isInstanceConfigured();
|
|
setIsConfigured(configured);
|
|
|
|
if (configured) {
|
|
setEndpoint(getApiEndpoint());
|
|
}
|
|
} else {
|
|
// Web environment - use default endpoint from env
|
|
setIsConfigured(true);
|
|
setEndpoint(process.env.NEXT_PUBLIC_API_BASE_URL || "");
|
|
}
|
|
}, []);
|
|
|
|
// Loading state
|
|
if (isConfigured === null) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
// Show setup screen if not configured (desktop only)
|
|
if (!isConfigured && isDesktopApp()) {
|
|
return (
|
|
<InstanceSetup
|
|
onComplete={(configuredEndpoint) => {
|
|
setEndpoint(configuredEndpoint);
|
|
setIsConfigured(true);
|
|
// Reload the app to apply new configuration
|
|
window.location.reload();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Render the main application
|
|
return <MainApp endpoint={endpoint} />;
|
|
}
|
|
```
|
|
|
|
### API Client Configuration
|
|
|
|
Configure the API client to use the stored endpoint:
|
|
|
|
```typescript
|
|
// src/lib/api-client.ts
|
|
import axios from "axios";
|
|
import { getApiEndpoint } from "@/lib/desktop/instance-config";
|
|
import { isDesktopApp } from "@/lib/desktop/utils";
|
|
|
|
function getBaseUrl(): string {
|
|
if (isDesktopApp()) {
|
|
return getApiEndpoint();
|
|
}
|
|
// Web fallback - use environment variable
|
|
return process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
|
}
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: getBaseUrl(),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// Re-configure API client when endpoint changes
|
|
export function reconfigureApiClient(): void {
|
|
apiClient.defaults.baseURL = getBaseUrl();
|
|
}
|
|
```
|
|
|
|
### Switching Instances
|
|
|
|
Allow users to switch between instances from settings:
|
|
|
|
```typescript
|
|
// src/components/desktop/InstanceSettings.tsx
|
|
import { useState } from "react";
|
|
import { getInstanceConfig, clearInstanceConfig, InstanceConfig } from "@/lib/desktop/instance-config";
|
|
import { isDesktopApp } from "@/lib/desktop/utils";
|
|
|
|
export function InstanceSettings() {
|
|
const [config, setConfig] = useState<InstanceConfig | null>(getInstanceConfig());
|
|
|
|
// Only show in desktop app
|
|
if (!isDesktopApp()) return null;
|
|
|
|
const handleSwitchInstance = () => {
|
|
// Clear current configuration
|
|
clearInstanceConfig();
|
|
// Reload to show setup screen
|
|
window.location.reload();
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-lg border border-custom-border-200 p-4">
|
|
<h3 className="text-lg font-medium text-custom-text-100">Instance Configuration</h3>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-custom-text-300">Type</span>
|
|
<span className="text-sm font-medium text-custom-text-100">
|
|
{config?.type === "cloud" ? "Plane Cloud" : "Self-hosted"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-custom-text-300">Endpoint</span>
|
|
<span className="text-sm font-medium text-custom-text-100">{config?.endpoint}</span>
|
|
</div>
|
|
|
|
{config?.configuredAt && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-custom-text-300">Configured</span>
|
|
<span className="text-sm text-custom-text-200">{new Date(config.configuredAt).toLocaleDateString()}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 border-t border-custom-border-200 pt-4">
|
|
<button
|
|
onClick={handleSwitchInstance}
|
|
className="w-full rounded-md border border-red-500 px-4 py-2 text-red-500 transition-colors hover:bg-red-500/10"
|
|
>
|
|
Switch Instance
|
|
</button>
|
|
<p className="mt-2 text-xs text-custom-text-400">
|
|
This will sign you out and allow you to connect to a different Plane instance.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Deep Link Support for Self-Hosted
|
|
|
|
Handle deep links that include the instance URL:
|
|
|
|
```typescript
|
|
// src/lib/desktop/deeplinks.ts
|
|
import { configureSelfHostedInstance, configureCloudInstance } from "./instance-config";
|
|
|
|
export function setupDeepLinkHandler() {
|
|
if (!window.todesktop) return;
|
|
|
|
window.todesktop.on("open-url", async (url: string) => {
|
|
const parsed = new URL(url);
|
|
|
|
// Handle instance configuration via deep link
|
|
// Example: plane://configure?type=self-hosted&endpoint=https://plane.example.com
|
|
if (parsed.pathname === "/configure" || parsed.pathname === "configure") {
|
|
const type = parsed.searchParams.get("type");
|
|
const endpoint = parsed.searchParams.get("endpoint");
|
|
|
|
if (type === "cloud") {
|
|
configureCloudInstance();
|
|
window.location.reload();
|
|
} else if (type === "self-hosted" && endpoint) {
|
|
configureSelfHostedInstance(endpoint);
|
|
window.location.reload();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle other deep links...
|
|
});
|
|
}
|
|
```
|
|
|
|
### Multi-Instance Support (Advanced)
|
|
|
|
For power users who need to connect to multiple instances:
|
|
|
|
```typescript
|
|
// src/lib/desktop/multi-instance.ts
|
|
|
|
export interface SavedInstance {
|
|
id: string;
|
|
name: string;
|
|
type: "cloud" | "self-hosted";
|
|
endpoint: string;
|
|
addedAt: number;
|
|
lastUsed?: number;
|
|
}
|
|
|
|
const INSTANCES_KEY = "plane-saved-instances";
|
|
const ACTIVE_INSTANCE_KEY = "plane-active-instance";
|
|
|
|
/**
|
|
* Get all saved instances
|
|
*/
|
|
export function getSavedInstances(): SavedInstance[] {
|
|
const stored = localStorage.getItem(INSTANCES_KEY);
|
|
if (!stored) return [];
|
|
try {
|
|
return JSON.parse(stored);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new instance
|
|
*/
|
|
export function addInstance(instance: Omit<SavedInstance, "id" | "addedAt">): SavedInstance {
|
|
const instances = getSavedInstances();
|
|
const newInstance: SavedInstance = {
|
|
...instance,
|
|
id: crypto.randomUUID(),
|
|
addedAt: Date.now(),
|
|
};
|
|
instances.push(newInstance);
|
|
localStorage.setItem(INSTANCES_KEY, JSON.stringify(instances));
|
|
return newInstance;
|
|
}
|
|
|
|
/**
|
|
* Remove an instance
|
|
*/
|
|
export function removeInstance(id: string): void {
|
|
const instances = getSavedInstances().filter((i) => i.id !== id);
|
|
localStorage.setItem(INSTANCES_KEY, JSON.stringify(instances));
|
|
}
|
|
|
|
/**
|
|
* Set active instance
|
|
*/
|
|
export function setActiveInstance(id: string): void {
|
|
const instances = getSavedInstances();
|
|
const instance = instances.find((i) => i.id === id);
|
|
if (instance) {
|
|
// Update last used timestamp
|
|
instance.lastUsed = Date.now();
|
|
localStorage.setItem(INSTANCES_KEY, JSON.stringify(instances));
|
|
localStorage.setItem(ACTIVE_INSTANCE_KEY, id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get active instance
|
|
*/
|
|
export function getActiveInstance(): SavedInstance | null {
|
|
const activeId = localStorage.getItem(ACTIVE_INSTANCE_KEY);
|
|
if (!activeId) return null;
|
|
return getSavedInstances().find((i) => i.id === activeId) || null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration Options
|
|
|
|
### todesktop.json Configuration
|
|
|
|
For advanced users integrating with CI/CD pipelines, create a `todesktop.json` in the project root:
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://unpkg.com/@todesktop/cli@latest/schemas/schema.json",
|
|
"schemaVersion": 1,
|
|
"id": "YOUR_TODESKTOP_APP_ID",
|
|
"icon": "./public/plane-logo.png",
|
|
"appId": "so.plane.desktop",
|
|
"packageJson": {
|
|
"name": "plane-desktop",
|
|
"productName": "Plane",
|
|
"version": "1.0.0",
|
|
"author": "Plane <[email protected]>"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Staging Configuration
|
|
|
|
Create `todesktop.staging.json` for staging builds:
|
|
|
|
```json
|
|
{
|
|
"extends": "./todesktop.json",
|
|
"id": "YOUR_STAGING_APP_ID",
|
|
"appId": "so.plane.desktop.staging",
|
|
"icon": "./public/plane-logo-staging.png",
|
|
"packageJson": {
|
|
"name": "plane-desktop-staging",
|
|
"productName": "Plane (Staging)"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementing Tabs
|
|
|
|
ToDesktop supports native tabs on macOS. This feature allows users to manage multiple Plane workspaces in a single window.
|
|
|
|
### Checking Tab Support
|
|
|
|
Tabs are only supported on macOS. Check for support before implementing:
|
|
|
|
```typescript
|
|
// src/lib/desktop/tabs.ts
|
|
import { nativeWindow } from "@todesktop/client-core";
|
|
|
|
export async function initializeTabs() {
|
|
// Check if running in ToDesktop environment
|
|
if (!window.todesktop) {
|
|
console.log("Not running in ToDesktop environment");
|
|
return;
|
|
}
|
|
|
|
const tabsSupported = await nativeWindow.areTabsSupported();
|
|
|
|
if (tabsSupported) {
|
|
console.log("Tabs are supported on this platform");
|
|
setupTabHandlers();
|
|
} else {
|
|
console.log("Tabs not supported - using standard window management");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Creating New Tabs
|
|
|
|
```typescript
|
|
// src/lib/desktop/tabs.ts
|
|
import { nativeWindow } from "@todesktop/client-core";
|
|
|
|
export async function createNewTab() {
|
|
if (!window.todesktop) return;
|
|
|
|
const tabsSupported = await nativeWindow.areTabsSupported();
|
|
|
|
if (tabsSupported) {
|
|
// Creates a new tab with the pre-defined app URL
|
|
await nativeWindow.createNewTab();
|
|
} else {
|
|
// Fallback: create a new window instead
|
|
await createNewWindow();
|
|
}
|
|
}
|
|
|
|
async function createNewWindow() {
|
|
await nativeWindow.create({
|
|
width: 1280,
|
|
height: 800,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
});
|
|
}
|
|
```
|
|
|
|
### Tab-Aware Navigation
|
|
|
|
When implementing workspace switching, consider tab behavior:
|
|
|
|
```typescript
|
|
// src/lib/desktop/workspace-tabs.ts
|
|
import { nativeWindow } from "@todesktop/client-core";
|
|
|
|
export async function openWorkspaceInNewTab(workspaceSlug: string) {
|
|
if (!window.todesktop) {
|
|
// Web fallback - open in new browser tab
|
|
window.open(`/${workspaceSlug}`, "_blank");
|
|
return;
|
|
}
|
|
|
|
const tabsSupported = await nativeWindow.areTabsSupported();
|
|
|
|
if (tabsSupported) {
|
|
// Create new tab and navigate to workspace
|
|
await nativeWindow.createNewTab();
|
|
// The new tab will load the app URL, then we navigate
|
|
// Use IPC or BroadcastChannel to coordinate navigation
|
|
} else {
|
|
// Create new window for the workspace
|
|
const windowRef = await nativeWindow.create({
|
|
width: 1280,
|
|
height: 800,
|
|
});
|
|
// Navigate the new window to the workspace
|
|
}
|
|
}
|
|
```
|
|
|
|
### Keyboard Shortcuts for Tabs
|
|
|
|
Register keyboard shortcuts for tab management:
|
|
|
|
```typescript
|
|
// src/lib/desktop/shortcuts.ts
|
|
export function registerTabShortcuts() {
|
|
if (!window.todesktop) return;
|
|
|
|
// These are typically handled by the OS on macOS
|
|
// but you can add custom shortcuts for cross-platform support
|
|
document.addEventListener("keydown", async (e) => {
|
|
// Cmd/Ctrl + T for new tab
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
|
|
e.preventDefault();
|
|
await createNewTab();
|
|
}
|
|
|
|
// Cmd/Ctrl + W to close current tab/window
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
|
|
e.preventDefault();
|
|
await nativeWindow.close();
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Multi-Window Support
|
|
|
|
ToDesktop supports creating and managing multiple windows for different views.
|
|
|
|
### Creating Additional Windows
|
|
|
|
```typescript
|
|
// src/lib/desktop/windows.ts
|
|
import { nativeWindow, views } from "@todesktop/client-core";
|
|
|
|
interface WindowOptions {
|
|
url?: string;
|
|
width?: number;
|
|
height?: number;
|
|
title?: string;
|
|
}
|
|
|
|
export async function createWindow(options: WindowOptions = {}) {
|
|
const { width = 1280, height = 800, title = "Plane" } = options;
|
|
|
|
const windowRef = await nativeWindow.create({
|
|
width,
|
|
height,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
title,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
});
|
|
|
|
return windowRef;
|
|
}
|
|
```
|
|
|
|
### Creating Windows with BrowserViews
|
|
|
|
For more complex layouts (like split views), use BrowserViews:
|
|
|
|
```typescript
|
|
// src/lib/desktop/browser-views.ts
|
|
import { nativeWindow, views } from "@todesktop/client-core";
|
|
|
|
export async function createSplitView(windowRef: string, leftUrl: string, rightUrl: string) {
|
|
// Create left view
|
|
const leftViewRef = await views.create({
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
});
|
|
|
|
// Create right view
|
|
const rightViewRef = await views.create({
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
});
|
|
|
|
// Add views to window
|
|
await nativeWindow.addBrowserView({
|
|
ref: windowRef,
|
|
viewRef: leftViewRef,
|
|
});
|
|
|
|
await nativeWindow.addBrowserView({
|
|
ref: windowRef,
|
|
viewRef: rightViewRef,
|
|
});
|
|
|
|
// Set bounds for split layout (50/50)
|
|
const windowSize = await nativeWindow.getSize({ ref: windowRef });
|
|
|
|
await views.setBounds({
|
|
ref: leftViewRef,
|
|
bounds: {
|
|
x: 0,
|
|
y: 0,
|
|
width: windowSize[0] / 2,
|
|
height: windowSize[1],
|
|
},
|
|
});
|
|
|
|
await views.setBounds({
|
|
ref: rightViewRef,
|
|
bounds: {
|
|
x: windowSize[0] / 2,
|
|
y: 0,
|
|
width: windowSize[0] / 2,
|
|
height: windowSize[1],
|
|
},
|
|
});
|
|
|
|
// Enable auto-resize
|
|
await views.setAutoResize({
|
|
ref: leftViewRef,
|
|
dimensions: { width: true, height: true, horizontal: false, vertical: false },
|
|
});
|
|
|
|
await views.setAutoResize({
|
|
ref: rightViewRef,
|
|
dimensions: { width: true, height: true, horizontal: true, vertical: false },
|
|
});
|
|
|
|
return { leftViewRef, rightViewRef };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Inter-Window Communication
|
|
|
|
ToDesktop provides two methods for communication between windows.
|
|
|
|
### Using BroadcastChannel API (Same Origin)
|
|
|
|
For windows sharing the same origin (recommended for Plane):
|
|
|
|
```typescript
|
|
// src/lib/desktop/broadcast.ts
|
|
|
|
type MessageType = "workspace:changed" | "issue:updated" | "notification:received" | "theme:changed";
|
|
|
|
interface BroadcastMessage<T = unknown> {
|
|
type: MessageType;
|
|
payload: T;
|
|
timestamp: number;
|
|
sourceWindowId?: string;
|
|
}
|
|
|
|
class DesktopBroadcast {
|
|
private channel: BroadcastChannel;
|
|
private listeners: Map<MessageType, Set<(payload: unknown) => void>>;
|
|
|
|
constructor(channelName: string = "plane-desktop") {
|
|
this.channel = new BroadcastChannel(channelName);
|
|
this.listeners = new Map();
|
|
|
|
this.channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
|
|
const { type, payload } = event.data;
|
|
const typeListeners = this.listeners.get(type);
|
|
|
|
if (typeListeners) {
|
|
typeListeners.forEach((callback) => callback(payload));
|
|
}
|
|
};
|
|
}
|
|
|
|
publish<T>(type: MessageType, payload: T) {
|
|
const message: BroadcastMessage<T> = {
|
|
type,
|
|
payload,
|
|
timestamp: Date.now(),
|
|
};
|
|
this.channel.postMessage(message);
|
|
}
|
|
|
|
subscribe<T>(type: MessageType, callback: (payload: T) => void) {
|
|
if (!this.listeners.has(type)) {
|
|
this.listeners.set(type, new Set());
|
|
}
|
|
this.listeners.get(type)!.add(callback as (payload: unknown) => void);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.listeners.get(type)?.delete(callback as (payload: unknown) => void);
|
|
};
|
|
}
|
|
|
|
destroy() {
|
|
this.channel.close();
|
|
this.listeners.clear();
|
|
}
|
|
}
|
|
|
|
export const desktopBroadcast = new DesktopBroadcast();
|
|
```
|
|
|
|
### Using ToDesktop IPC (Cross-Domain)
|
|
|
|
For windows with different domains, use the IPC plugin:
|
|
|
|
```bash
|
|
npm install @todesktop/client-ipc
|
|
```
|
|
|
|
```typescript
|
|
// src/lib/desktop/ipc.ts
|
|
import { publish, subscribe } from "@todesktop/client-ipc";
|
|
|
|
export async function sendToAllWindows(channel: string, data: unknown) {
|
|
await publish(channel, data);
|
|
}
|
|
|
|
export function listenForMessages(channel: string, callback: (data: unknown) => void) {
|
|
return subscribe(channel, callback);
|
|
}
|
|
|
|
// Usage example
|
|
export function setupIPCListeners() {
|
|
// Listen for workspace changes from other windows
|
|
listenForMessages("workspace:selected", (data) => {
|
|
console.log("Workspace selected in another window:", data);
|
|
});
|
|
|
|
// Listen for focus requests
|
|
listenForMessages("window:focus", async (data: { windowId: string }) => {
|
|
const currentWindowId = await nativeWindow.getId();
|
|
if (data.windowId === currentWindowId) {
|
|
await nativeWindow.focus();
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Browser Controls & Navigation
|
|
|
|
Desktop apps don't have browser navigation by default. Implement custom controls if needed.
|
|
|
|
### Adding Navigation Buttons
|
|
|
|
```typescript
|
|
// src/components/desktop/NavigationControls.tsx
|
|
import { useEffect, useState } from "react";
|
|
import { ChevronLeft, ChevronRight, RotateCw } from "lucide-react";
|
|
|
|
export function NavigationControls() {
|
|
const [canGoBack, setCanGoBack] = useState(false);
|
|
const [canGoForward, setCanGoForward] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!window.todesktop) return;
|
|
|
|
const updateNavigationState = async () => {
|
|
const webContents = window.todesktop.contents;
|
|
setCanGoBack(await webContents.canGoBack());
|
|
setCanGoForward(await webContents.canGoForward());
|
|
};
|
|
|
|
// Update on navigation events
|
|
window.todesktop.contents.on("navigate", updateNavigationState);
|
|
updateNavigationState();
|
|
|
|
return () => {
|
|
window.todesktop.contents.off("navigate", updateNavigationState);
|
|
};
|
|
}, []);
|
|
|
|
// Don't render in web browser
|
|
if (!window.todesktop) return null;
|
|
|
|
const handleBack = () => window.todesktop?.contents.goBack();
|
|
const handleForward = () => window.todesktop?.contents.goForward();
|
|
const handleReload = () => window.location.reload();
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 px-2">
|
|
<button
|
|
onClick={handleBack}
|
|
disabled={!canGoBack}
|
|
className="p-1 rounded hover:bg-custom-background-80 disabled:opacity-50"
|
|
title="Go Back"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleForward}
|
|
disabled={!canGoForward}
|
|
className="p-1 rounded hover:bg-custom-background-80 disabled:opacity-50"
|
|
title="Go Forward"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
<button onClick={handleReload} className="p-1 rounded hover:bg-custom-background-80" title="Reload">
|
|
<RotateCw className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### CSS for Desktop-Only Elements
|
|
|
|
Show navigation controls only in the desktop app:
|
|
|
|
```css
|
|
/* src/styles/desktop.css */
|
|
|
|
/* Hide by default (web) */
|
|
.desktop-only {
|
|
display: none;
|
|
}
|
|
|
|
/* Show only in ToDesktop environment */
|
|
.todesktop .desktop-only {
|
|
display: flex;
|
|
}
|
|
|
|
/* Adjust for traffic lights on macOS */
|
|
.todesktop.darwin .titlebar-drag-region {
|
|
padding-left: 78px; /* Space for window controls */
|
|
}
|
|
```
|
|
|
|
Apply the `.todesktop` class to the root element:
|
|
|
|
```typescript
|
|
// src/lib/desktop/init.ts
|
|
export function initDesktopEnvironment() {
|
|
if (window.todesktop) {
|
|
document.documentElement.classList.add("todesktop");
|
|
|
|
// Detect platform
|
|
const platform = window.todesktop.platform;
|
|
document.documentElement.classList.add(platform); // 'darwin', 'win32', 'linux'
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Native API Integration
|
|
|
|
### Installing the Client API
|
|
|
|
```bash
|
|
npm install @todesktop/client-core
|
|
```
|
|
|
|
### System Notifications
|
|
|
|
```typescript
|
|
// src/lib/desktop/notifications.ts
|
|
export async function showDesktopNotification(
|
|
title: string,
|
|
body: string,
|
|
options?: {
|
|
onClick?: () => void;
|
|
}
|
|
) {
|
|
if (!window.todesktop) {
|
|
// Fallback to web notifications
|
|
if ("Notification" in window && Notification.permission === "granted") {
|
|
new Notification(title, { body });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Use native notifications via ToDesktop
|
|
const notification = new Notification(title, {
|
|
body,
|
|
silent: false,
|
|
});
|
|
|
|
if (options?.onClick) {
|
|
notification.onclick = options.onClick;
|
|
}
|
|
}
|
|
```
|
|
|
|
### App Badge (macOS Dock / Windows Taskbar)
|
|
|
|
```typescript
|
|
// src/lib/desktop/badge.ts
|
|
import { app } from "@todesktop/client-core";
|
|
|
|
export async function updateBadgeCount(count: number) {
|
|
if (!window.todesktop) return;
|
|
|
|
if (count > 0) {
|
|
await app.setBadgeCount(count);
|
|
} else {
|
|
await app.setBadgeCount(0); // Removes badge
|
|
}
|
|
}
|
|
|
|
// Usage: Show unread notification count
|
|
export function useBadgeSync() {
|
|
useEffect(() => {
|
|
const unsubscribe = notificationStore.subscribe((state) => {
|
|
updateBadgeCount(state.unreadCount);
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
}
|
|
```
|
|
|
|
### System Tray
|
|
|
|
```typescript
|
|
// src/lib/desktop/tray.ts
|
|
import { tray, nativeWindow } from "@todesktop/client-core";
|
|
|
|
export async function setupSystemTray() {
|
|
if (!window.todesktop) return;
|
|
|
|
// Tray is configured in ToDesktop Builder UI
|
|
// But you can interact with it programmatically
|
|
|
|
// Example: Update tray tooltip
|
|
await tray.setToolTip("Plane - Project Management");
|
|
}
|
|
```
|
|
|
|
### Deep Links
|
|
|
|
Handle custom URL schemes (e.g., `plane://`):
|
|
|
|
```typescript
|
|
// src/lib/desktop/deeplinks.ts
|
|
export function setupDeepLinkHandler() {
|
|
if (!window.todesktop) return;
|
|
|
|
window.todesktop.on("open-url", (url: string) => {
|
|
// Parse the deep link URL
|
|
const parsed = new URL(url);
|
|
|
|
// Handle different paths
|
|
if (parsed.pathname.startsWith("/issue/")) {
|
|
const issueId = parsed.pathname.replace("/issue/", "");
|
|
navigateToIssue(issueId);
|
|
} else if (parsed.pathname.startsWith("/workspace/")) {
|
|
const workspaceSlug = parsed.pathname.replace("/workspace/", "");
|
|
navigateToWorkspace(workspaceSlug);
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CLI Setup for Automated Builds
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
# Global installation
|
|
npm install -g @todesktop/cli
|
|
|
|
# Or as dev dependency (recommended for CI/CD)
|
|
pnpm add -D @todesktop/cli
|
|
```
|
|
|
|
### Package.json Scripts
|
|
|
|
Add these scripts to `apps/web/package.json`:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"desktop:build": "todesktop build",
|
|
"desktop:build:staging": "todesktop build --config todesktop.staging.json",
|
|
"desktop:release": "todesktop release",
|
|
"desktop:release:staging": "todesktop release --config todesktop.staging.json"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Runtime Package
|
|
|
|
Install the runtime for auto-updates and crash reporting:
|
|
|
|
```bash
|
|
pnpm add @todesktop/runtime
|
|
```
|
|
|
|
Initialize in your app's entry point:
|
|
|
|
```typescript
|
|
// src/main.tsx or src/index.tsx
|
|
import { todesktop } from "@todesktop/runtime";
|
|
|
|
// Initialize ToDesktop runtime (must be called early)
|
|
if (window.todesktop) {
|
|
todesktop.init();
|
|
}
|
|
```
|
|
|
|
### CI/CD Integration
|
|
|
|
Example GitHub Actions workflow:
|
|
|
|
```yaml
|
|
# .github/workflows/desktop-release.yml
|
|
name: Desktop Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- "desktop-v*"
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: pnpm/action-setup@v2
|
|
with:
|
|
version: 10.24.0
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "22"
|
|
cache: "pnpm"
|
|
|
|
- run: pnpm install
|
|
|
|
- name: Build Desktop App
|
|
env:
|
|
TODESKTOP_EMAIL: ${{ secrets.TODESKTOP_EMAIL }}
|
|
TODESKTOP_ACCESS_TOKEN: ${{ secrets.TODESKTOP_ACCESS_TOKEN }}
|
|
run: |
|
|
pnpm --filter=web desktop:release
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### 1. Feature Detection
|
|
|
|
Always check for ToDesktop environment before using desktop APIs:
|
|
|
|
```typescript
|
|
export function isDesktopApp(): boolean {
|
|
return typeof window !== "undefined" && !!window.todesktop;
|
|
}
|
|
|
|
export function isMacOS(): boolean {
|
|
return isDesktopApp() && window.todesktop?.platform === "darwin";
|
|
}
|
|
|
|
export function isWindows(): boolean {
|
|
return isDesktopApp() && window.todesktop?.platform === "win32";
|
|
}
|
|
```
|
|
|
|
### 2. Graceful Degradation
|
|
|
|
Ensure your app works both as a web app and desktop app:
|
|
|
|
```typescript
|
|
export async function copyToClipboard(text: string) {
|
|
if (window.todesktop) {
|
|
// Use native clipboard API
|
|
await window.todesktop.clipboard.writeText(text);
|
|
} else {
|
|
// Fallback to web API
|
|
await navigator.clipboard.writeText(text);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Platform-Specific Styling
|
|
|
|
Account for platform differences:
|
|
|
|
```typescript
|
|
// src/hooks/useDesktopPlatform.ts
|
|
export function useDesktopPlatform() {
|
|
const [platform, setPlatform] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (window.todesktop) {
|
|
setPlatform(window.todesktop.platform);
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
isDesktop: !!platform,
|
|
isMac: platform === "darwin",
|
|
isWindows: platform === "win32",
|
|
isLinux: platform === "linux",
|
|
};
|
|
}
|
|
```
|
|
|
|
### 4. Keyboard Shortcuts
|
|
|
|
Use platform-appropriate modifier keys:
|
|
|
|
```typescript
|
|
export function getPlatformModifier(): string {
|
|
if (window.todesktop?.platform === "darwin") {
|
|
return "⌘"; // Command key on Mac
|
|
}
|
|
return "Ctrl";
|
|
}
|
|
|
|
// Display: "⌘+K" on Mac, "Ctrl+K" on Windows/Linux
|
|
```
|
|
|
|
### 5. Window State Persistence
|
|
|
|
Save and restore window positions:
|
|
|
|
```typescript
|
|
// ToDesktop Builder handles this automatically when configured
|
|
// But you can also manage it programmatically for custom windows
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
#### 1. API Not Available
|
|
|
|
**Problem**: `window.todesktop is undefined`
|
|
|
|
**Solution**: The code is running in a web browser, not the desktop app. Always check for the ToDesktop environment:
|
|
|
|
```typescript
|
|
if (window.todesktop) {
|
|
// Desktop-specific code
|
|
}
|
|
```
|
|
|
|
#### 2. Tabs Not Working
|
|
|
|
**Problem**: `createNewTab()` has no effect
|
|
|
|
**Solution**: Tabs are only supported on macOS. Check support first:
|
|
|
|
```typescript
|
|
const tabsSupported = await nativeWindow.areTabsSupported();
|
|
```
|
|
|
|
#### 3. IPC Messages Not Received
|
|
|
|
**Problem**: Messages between windows aren't being received
|
|
|
|
**Solution**:
|
|
|
|
- Ensure both windows have the IPC plugin enabled
|
|
- Verify the channel names match exactly
|
|
- Check that the IPC client library is installed in both contexts
|
|
|
|
#### 4. Build Failures
|
|
|
|
**Problem**: `todesktop build` fails
|
|
|
|
**Solution**:
|
|
|
|
- Verify `todesktop.json` has the correct app ID
|
|
- Ensure you're logged in: `todesktop login`
|
|
- Check that required fields are present (icon, author)
|
|
|
|
### Debug Mode
|
|
|
|
Enable verbose logging:
|
|
|
|
```typescript
|
|
if (window.todesktop) {
|
|
console.log("ToDesktop Version:", window.todesktop.version);
|
|
console.log("Platform:", window.todesktop.platform);
|
|
console.log("App ID:", window.todesktop.appId);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## TypeScript Definitions
|
|
|
|
Add type definitions for ToDesktop APIs:
|
|
|
|
```typescript
|
|
// src/types/todesktop.d.ts
|
|
declare global {
|
|
interface Window {
|
|
todesktop?: {
|
|
version: string;
|
|
platform: "darwin" | "win32" | "linux";
|
|
appId: string;
|
|
contents: {
|
|
goBack(): Promise<void>;
|
|
goForward(): Promise<void>;
|
|
canGoBack(): Promise<boolean>;
|
|
canGoForward(): Promise<boolean>;
|
|
on(event: string, callback: () => void): void;
|
|
off(event: string, callback: () => void): void;
|
|
};
|
|
clipboard: {
|
|
writeText(text: string): Promise<void>;
|
|
readText(): Promise<string>;
|
|
};
|
|
on(event: string, callback: (...args: unknown[]) => void): void;
|
|
};
|
|
}
|
|
}
|
|
|
|
export {};
|
|
```
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
- [ToDesktop Documentation](https://www.todesktop.com/docs/introduction/getting-started)
|
|
- [ToDesktop Builder Basics](https://www.todesktop.com/docs/introduction/basics)
|
|
- [ToDesktop API Reference](https://www.todesktop.com/docs/API/client-core.nativewindow)
|
|
- [Multi-Window Tutorial](https://www.todesktop.com/docs/tutorials/multi-window-todomvc)
|
|
- [Inter-Window Communication](https://www.todesktop.com/docs/windows/communicating-between-windows)
|
|
- [ToDesktop CLI (npm)](https://www.npmjs.com/package/@todesktop/cli)
|
|
- [GitHub Quick Start](https://github.com/ToDesktop/todesktop-quick-start)
|