Deno で Desktop アプリを作れるようになっていた
Deno v2.9.0 以降で `deno desktop` コマンドが使えるようになりました。`deno desktop` コマンドは単一の TypeScript ファイルから Next.js プロジェクトまで、デスクトップアプリに変換できます。この記事では Deno でデスクトップアプリを作る方法について紹介します。
Deno v2.9.0 以降で deno desktop コマンドが使えるようになりました。deno desktop コマンドは単一の TypeScript ファイルから Next.js プロジェクトまで、デスクトップアプリに変換できます。今までも Web 技術を使用してデスクトップアプリを作るためのフレームワークとして、Electron や Tauri, Electrobun などがありましたが、それぞれにトレードオフがありました。Electron は Node.js を使用しているため、Node.js のエコシステムを活用できる一方で、アプリのサイズが大きくなりがちです。Tauri はフロントエンドに Web 技術を使える一方で、バックエンドのロジックを Rust で書く必要があるため、Node.js(npm)のサーバーサイドのエコシステムをそのまま活用できません。
deno desktop コマンドはデフォルトで軽量で、Node.js との互換性を備えているのが特徴です。デフォルトでは WebView バックエンドを使用しているため、バイナリサイズを小さく抑えつつ、Node.js のエコシステムを活用できます。一方で WebView バックエンドの場合 OS ごとに異なる WebView を使用するため、見た目の違いが出てしまうことがあります。バンドルサイズは増えてしまうものの、Chromium バックエンドを使用することで、OS 間で見た目の違いが出ないようにできます。
IPC (Inter-Process Communication) の代わりにプロセス内バインディングを使用している点も特徴です。Electron や Tauri では、IPC を使用してフロントエンドとバックエンドの間で通信します。IPC はプロセス間通信のため、通信のたびにシリアライズとデシリアライズが必要となり、パフォーマンスに影響を与えることがあります。deno desktop コマンドでは、ランタイムとフロントエンドが同じプロセス内で動作するため、シリアライズとデシリアライズのオーバーヘッドがなく、パフォーマンスが向上します。また 1 つのマシンで Windows, macOS, Linux 向けにビルドできるクロスプラットフォーム対応も備えています。
この記事では Deno でデスクトップアプリを作る方法について紹介します。
はじめての Deno デスクトップアプリ
最も簡単な Deno デスクトップアプリを作ってみましょう。Deno.serve() 関数を使用して単純な HTML ページを表示するアプリを作成します。
Deno.serve(() =>
new Response("<h1>Hello, desktop</h1>", {
headers: { "content-type": "text/html" },
})
);Deno のデスクトップアプリでは、Deno.serve() 関数を使用してローカル HTTP 経由で HTML を返し、WebView で表示できます。HTTP リクエストを通じて HTML, CSS, JavaScript を返すため 1 ラウンドトリップの通信が必要となりますが、Web アプリと同じコード(fetch, websocket, cookie など)を使用できるという利点からこの方法が採用されています。Deno のランタイムはバイナリが開始されると、ローカルの未使用のポートを自動的に選択して、http://127.0.0.1:<port> でリッスンします。リスナーの準備ができると、WebView が自動的に開き、Deno.serve() で返された HTML が表示されます。サーバーが外部に公開されることはありません。
deno desktop コマンドを使用して、上記の TypeScript ファイルをデスクトップアプリに変換します。
deno desktop main.tsプロジェクトルートに deno.json ファイルが存在しない場合、見つかるまで親ディレクトリを遡るような挙動をして、最終的にコンパイルに失敗してしまいました。設定はほぼ不要ですが、なにかしら deno.json ファイルを作成しておくとよいでしょう。
コンパイルに成功すると、macOS では deno-desktop-test.app(deno-desktop-test はディレクトリ名)というアプリケーションが作成されます。ダブルクリックでアプリケーションを起動すると、Hello, desktop と表示されることが確認できます。

--hmr オプションを付与することで、アプリケーションを起動したまま、コードの変更を反映させることができます。
deno desktop --hmr main.tsNext.js プロジェクトをデスクトップアプリに変換する
deno desktop コマンドの引数にディレクトリを指定するとフレームワークが自動で検出され、適切なエントリポイントが選択され、ビルド出力がバイナリに変換されます。フレームワークを使用するためにコードの変更や特別な設定は不要です。Next.js, Astro, Fresh, Remix, Nuxt, SvelteKit, SolidStart, TanStack Start, Vite など主要なフレームワークがサポートされています。
ここでは Next.js を使用して Todo アプリを作成し、デスクトップアプリに変換してみます。まずは Deno で Next.js プロジェクトを作成します。
deno run -A npm:create-next-app@latestNext.js には CommonJS モジュールに依存したパッケージがいくつかあるため、互換性を持たせるため deno.json ファイルに以下を追記する必要があります。
{
"unstable": [
"detect-cjs",
"node-globals",
"unsafe-proto",
"sloppy-imports"
]
}続いて依存関係をインストールします。
deno install --allow-scripts最も単純な Todo アプリを作成しました。データは localStorage に保存されます。
app/page.tsx
"use client";
import { useEffect, useState } from "react";
type Todo = {
id: number;
text: string;
done: boolean;
};
const STORAGE_KEY = "todos";
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState("");
// 初回マウント時に localStorage から復元
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
setTodos(JSON.parse(saved));
}
}, []);
// todos が変わるたびに保存
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]);
const addTodo = () => {
const text = input.trim();
if (!text) return;
setTodos((prev) => [...prev, { id: Date.now(), text, done: false }]);
setInput("");
};
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
};
const deleteTodo = (id: number) => {
const confirmed = confirm("本当に削除しますか?");
if (!confirmed) return;
setTodos((prev) => prev.filter((t) => t.id !== id));
};
return (
<div className="flex flex-1 justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="w-full max-w-md px-6 py-16">
<h1 className="mb-8 text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
Todo
</h1>
<form
onSubmit={(e) => {
e.preventDefault();
addTodo();
}}
className="mb-6 flex gap-2"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="やることを入力…"
className="flex-1 rounded-lg border border-zinc-300 bg-white px-4 py-2 text-black outline-none focus:border-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-50"
/>
<button
type="submit"
className="rounded-lg bg-foreground px-4 py-2 font-medium text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc]"
>
追加
</button>
</form>
<ul className="flex flex-col gap-2">
{todos.length === 0 && (
<li className="py-4 text-center text-zinc-500 dark:text-zinc-400">
タスクはありません
</li>
)}
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"
>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
className="h-4 w-4 cursor-pointer"
/>
<span
className={`flex-1 text-black dark:text-zinc-50 ${
todo.done
? "text-zinc-400 line-through dark:text-zinc-600"
: ""
}`}
>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
className="text-sm text-zinc-400 transition-colors hover:text-red-500"
aria-label="削除"
>
削除
</button>
</li>
))}
</ul>
</main>
</div>
);
}deno desktop コマンドを実行する前に、それぞれのフレームワークのビルドコマンドを実行する必要があります。Next.js の場合は以下のコマンドでビルドします。
Next.js の場合は環境変数 __NEXT_PRIVATE_CPU_PROFILE にアクセスするため、--allow-env オプションと --allow-sys オプションを付与する必要がありました。
# .next ディレクトリが作成される
npx next build
deno desktop --allow-env --allow-sys .成果物として deno-desktop-next.app(macOS の場合)が作成されました。早速アプリケーションを起動してみましょう。deno-desktop-next.app をダブルクリックすると、Todo アプリが表示されることが確認できます。

メニュー
デスクトップアプリのメニューにはアプリケーションメニュー(macOS の場合はメニューバー、Windows の場合はウィンドウ上部のメニュー)とコンテキストメニュー(右クリックで表示されるメニュー)があります。どちらも Deno.MenuItem 型を使用しています。
type MenuItem =
// クリック可能なメニューアイテム。
| {
item: {
label: string;
id?: string; // クリック時に返される id
accelerator?: string; // e.g. "CmdOrCtrl+S", "F11"
enabled: boolean;
};
}
// ネストされたサブメニュー
| {
submenu: {
label: string;
items: MenuItem[];
};
}
// 区切り線
| "separator"
// OS ごとに異なる役割を持つメニューアイテム。macOS の場合は「サービス」や「ウィンドウ」、Windows の場合は「閉じる」など。
| { role: { role: string } };
アプリケーションメニューは new Deno.BrowserWindow({ title: "My App" }); で作成したウィンドウの setApplicationMenu() メソッドで設定できます。最初の new Deno.BrowserWindow() は起動時に開く初期ウィンドウを表し、2 つ目以降のインスタンスは新しいウィンドウを作成します。setApplicationMenu() メソッドの引数には Deno.MenuItem[] 型の配列を渡せます。以下では、File メニューに New, Open, Save の 3 つのメニューを追加しています。
メニューがクリックされた時のイベントは menuclick イベントで受け取ることができます。
const win = new Deno.BrowserWindow({ title: "My App" });
win.setApplicationMenu([
{
// macOS の場合最上位のメニューはアプリケーション名が表示され、ラベル名は置き換えられる
// quit などの標準的な role はここに配置すべき
submenu: {
label: "My App",
items: [
{ role: { role: "about" } },
{
item: {
label: "ラベル名がメニュー項目として表示されます",
id: "custom-menu-item",
enabled: true,
},
},
{ role: { role: "quit" } },
],
},
},
{
submenu: {
label: "File",
items: [
{
item: {
label: "New",
id: "new",
accelerator: "CmdOrCtrl+N",
enabled: true,
},
},
{
item: {
label: "Open…",
id: "open",
// accelerator はショートカットキーを設定することができる。
accelerator: "CmdOrCtrl+O",
enabled: true,
},
},
"separator",
{
item: {
label: "Save",
id: "save",
accelerator: "CmdOrCtrl+S",
enabled: true,
},
},
],
},
},
{
submenu: {
label: "Edit",
items: [
{ role: { role: "undo" } },
{ role: { role: "redo" } },
"separator",
{ role: { role: "cut" } },
{ role: { role: "copy" } },
{ role: { role: "paste" } },
],
},
},
]);
win.addEventListener("menuclick", (e) => {
switch (e.detail.id) {
case "new":
console.log("New menu item clicked");
break;
case "open":
console.log("Open menu item clicked");
break;
case "save":
console.log("Save menu item clicked");
break;
default:
console.log("Unknown menu item clicked");
break;
}
});アプリケーションを起動して確認してみましょう。console.log の出力を確認するには --inspect オプションを付与して起動する必要があります。
deno desktop --inspect main.ts設定したメニューが表示されていることが確認できます。

コンテキストメニューは win.showContextMenu() メソッドで表示できます。一般的にコンテキストメニューは右クリックで表示されるため、ウィンドウの mousedown イベントで右クリックを検知して表示できます。
const contextMenu: Deno.MenuItem[] = [
{
item: {
label: "Copy",
id: "copy",
accelerator: "CmdOrCtrl+C",
enabled: true,
},
},
{
item: {
label: "Paste",
id: "paste",
accelerator: "CmdOrCtrl+V",
enabled: true,
},
},
];
win.addEventListener("mousedown", (e) => {
// e.button は 0 が左クリック、1 が中クリック、2 が右クリックを表す
if (e.button === 2) {
// クリックした位置にコンテキストメニューを表示する
win.showContextMenu(e.clientX, e.clientY, contextMenu);
}
});
// contextmenu がクリックされた時のイベント
win.addEventListener("contextmenuclick", (e) => {
if (e.detail.id === "copy") { /* ... */ }
if (e.detail.id === "paste") { /* ... */ }
});コンテキストメニューは右クリックで表示されることが確認できます。

通知
通知はブラウザ標準の Notification API を使用して表示できます。以下ではボタンをクリックすると通知が表示され、通知をクリックするとアプリがフォーカスされるようにしています。Web アプリと同じように、通知が許可されているかどうかの状態を確認できます。
// 画面(webview に表示する HTML)
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>通知デモ</title>
</head>
<body>
<button id="notify">通知を表示</button>
<script>
document.getElementById("notify").addEventListener("click", async () => {
// webview → Deno 側の関数を呼ぶ
await bindings.notify("こんにちは");
});
</script>
</body>
</html>`;
// 起動時のウィンドウを取得
const win = new Deno.BrowserWindow();
// webview から bindings.notify(...) で呼べる Deno 側の関数を登録
win.bind("notify", async (title) => {
// title は BrowserWindowValue 型。
const t = String(title ?? "");
// 通知の権限を確認。許可されていなければリクエストする
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
}
// New Notification() で通知を表示。クリック時にウィンドウを前面に出す
const n = new Notification(t);
n.addEventListener("click", () => win.focus());
});
Deno.serve(
() => new Response(html, { headers: { "content-type": "text/html" } }),
);WebView 内の HTML から Deno 側の関数を呼び出すためにバインディングを使用しています。win.bind("notify", ...) でバインディングを登録すると、WebView 内の HTML から bindings.notify(...) で呼び出すことができます。バインディングは IPC を使用せず、同じプロセス内で呼び出されます。これにより、プロセス間の通信が不要になります。
Deno 側で登録した notify 関数は Notification API を使用して通知を表示します。ユーザーが通知を許可しているかどうかをまず Notification.permission で確認し、許可されていなければ Notification.requestPermission() で通知の権限をリクエストします。通知の呼び出しは new Notification() で行い、通知がクリックされた時にアプリをフォーカスするために win.focus() を呼び出しています。
macOS の場合デスクトップアプリケーションの通知を許可するには、アプリケーションの安定したバンドル ID が必要です。デフォルトではアドホック署名が使用され、ビルドのたびに ID が変わりうるため安定しませんが、一時的な ID で通知のテストを行う用途では問題ありません。一方でアプリを配布する場合は安定したバンドル ID を設定する必要があります。--hmr でホットリロードで起動している場合は、キャッシュされたアドホック署名が再利用されます。
実際にアプリケーションを起動して、通知が表示されることを確認してみましょう。

まとめ
- Deno v2.9.0 以降で
deno desktopコマンドが使用できるようになった deno desktopコマンドは単一の TypeScript ファイルから Next.js プロジェクトまで、デスクトップアプリに変換できる- デフォルトでは WebView バックエンドを使用しているため、バイナリサイズを小さく抑えつつ、Node.js のエコシステムを活用できる
- IPC の代わりにプロセス内バインディングを使用しているため、シリアライズとデシリアライズのオーバーヘッドがなく、パフォーマンスが向上する
- 1 つのマシンで Windows, macOS, Linux 向けにビルドできるクロスプラットフォーム対応も備えている
- デスクトップアプリのメニューにはアプリケーションメニューとコンテキストメニューがあり、どちらも
Deno.MenuItem型を使用して作成できる - 通知はブラウザ標準の Notification API を使用して表示する



