ケーススタディ#5-5: デスクトップアプリを作る - Windows / macOS / Linux対応
このケーススタディで作るもの
Cursor + Claudeを使って、Windows、macOS、Linuxで動作する本格的なデスクトップアプリを作ります。
機能: - ✅ ウィンドウのカスタマイズ(サイズ、タイトル) - ✅ ネイティブメニューバー - ✅ ファイルピッカー(開く/保存) - ✅ キーボードショートカット - ✅ マウス操作に最適化されたUI - ✅ 複数ウィンドウ対応
技術スタック: - Flutter 3.27+ - window_manager(ウィンドウ制御) - file_picker(ファイル選択) - path_provider(ファイルパス取得)
対応プラットフォーム: - 🪟 Windows 10/11 - 🍎 macOS 10.14+ - 🐧 Linux (Ubuntu, Fedora等)
完成イメージ
┌────────────────────────────────────────┐
│ File Edit View Help │ ← メニューバー
├────────────────────────────────────────┤
│ ┌────────┐ │
│ │ サイド │ メインコンテンツエリア │
│ │ バー │ │
│ │ │ デスクトップらしい │
│ │ - Home │ 広々としたレイアウト │
│ │ - Files│ │
│ │ - Edit │ マウス操作に最適化 │
│ │ │ │
│ └────────┘ │
├────────────────────────────────────────┤
│ Status: Ready │ ← ステータスバー
└────────────────────────────────────────┘
Step 1: デスクトップサポートの有効化
既存プロジェクトでデスクトップを有効にする
# Windows
flutter config --enable-windows-desktop
flutter create --platforms=windows .
# macOS
flutter config --enable-macos-desktop
flutter create --platforms=macos .
# Linux
flutter config --enable-linux-desktop
flutter create --platforms=linux .
# または全プラットフォームを一度に
flutter create --platforms=windows,macos,linux .
プロジェクト構造の確認
my_desktop_app/
├── lib/
├── windows/ ← Windowsネイティブコード
├── macos/ ← macOSネイティブコード
├── linux/ ← Linuxネイティブコード
└── pubspec.yaml
Step 2: ウィンドウマネージャーのセットアップ
依存関係の追加
Cursorに以下をリクエスト:
pubspec.yamlに以下のパッケージを追加してください:
- window_manager: ^0.3.0
- file_picker: ^6.0.0
- path_provider: ^2.1.0
ウィンドウの初期設定
Cursorに以下をリクエスト:
main.dartでウィンドウマネージャーを初期化し、
以下の設定をしてください:
- 初期サイズ: 1200x800
- 最小サイズ: 800x600
- タイトル: "My Desktop App"
- 中央配置
生成されるコード例:
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// デスクトッププラットフォームのみで初期化
await windowManager.ensureInitialized();
WindowOptions windowOptions = const WindowOptions(
size: Size(1200, 800),
minimumSize: Size(800, 600),
center: true,
title: 'My Desktop App',
titleBarStyle: TitleBarStyle.normal,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My Desktop App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
Step 3: デスクトップレイアウトの実装
サイドバー + メインコンテンツのレイアウト
Cursorに以下をリクエスト:
デスクトップらしいレイアウトを作成してください:
- 左側: 固定幅200pxのサイドバー(ナビゲーション)
- 右側: 可変幅のメインコンテンツエリア
- 上部: アプリバー
- 下部: ステータスバー
生成されるコード例:
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
final List<String> _pages = ['Home', 'Files', 'Settings'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Desktop App'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {},
),
],
),
body: Row(
children: [
// サイドバー
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) {
setState(() {
_selectedIndex = index;
});
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Files'),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
// メインコンテンツ
Expanded(
child: _buildMainContent(),
),
],
),
bottomNavigationBar: _buildStatusBar(),
);
}
Widget _buildMainContent() {
return Center(
child: Text(
'${_pages[_selectedIndex]} Page',
style: Theme.of(context).textTheme.headlineMedium,
),
);
}
Widget _buildStatusBar() {
return Container(
height: 24,
color: Colors.grey[200],
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Text(
'Status: Ready',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
);
}
}
Step 4: ファイルピッカーの実装
ファイルの開く/保存ダイアログ
Cursorに以下をリクエスト:
file_pickerを使って、以下の機能を実装してください:
1. ファイルを開くボタン(テキストファイルのみ)
2. ファイルを保存ボタン
3. 選択したファイルパスを表示
生成されるコード例:
import 'package:file_picker/file_picker.dart';
import 'dart:io';
class FilesPage extends StatefulWidget {
const FilesPage({super.key});
@override
State<FilesPage> createState() => _FilesPageState();
}
class _FilesPageState extends State<FilesPage> {
String? _selectedFilePath;
String _fileContent = '';
Future<void> _openFile() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['txt', 'md', 'json'],
);
if (result != null) {
final file = File(result.files.single.path!);
setState(() {
_selectedFilePath = file.path;
_fileContent = file.readAsStringSync();
});
}
}
Future<void> _saveFile() async {
String? outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'ファイルを保存',
fileName: 'document.txt',
);
if (outputPath != null) {
final file = File(outputPath);
await file.writeAsString(_fileContent);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存しました: $outputPath')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: _openFile,
icon: const Icon(Icons.folder_open),
label: const Text('ファイルを開く'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _saveFile,
icon: const Icon(Icons.save),
label: const Text('ファイルを保存'),
),
],
),
const SizedBox(height: 16),
if (_selectedFilePath != null)
Text('選択中: $_selectedFilePath'),
const SizedBox(height: 16),
Expanded(
child: TextField(
controller: TextEditingController(text: _fileContent),
onChanged: (value) => _fileContent = value,
maxLines: null,
expands: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'ファイル内容がここに表示されます',
),
),
),
],
),
);
}
}
Step 5: キーボードショートカットの実装
ショートカットキーの追加
Cursorに以下をリクエスト:
以下のキーボードショートカットを実装してください:
- Ctrl/Cmd + O: ファイルを開く
- Ctrl/Cmd + S: ファイルを保存
- Ctrl/Cmd + Q: アプリを終了
生成されるコード例:
import 'package:flutter/services.dart';
class ShortcutsPage extends StatelessWidget {
final VoidCallback onOpen;
final VoidCallback onSave;
final VoidCallback onQuit;
const ShortcutsPage({
super.key,
required this.onOpen,
required this.onSave,
required this.onQuit,
});
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyO):
const OpenFileIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const SaveFileIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyQ):
const QuitAppIntent(),
},
child: Actions(
actions: {
OpenFileIntent: CallbackAction<OpenFileIntent>(
onInvoke: (_) => onOpen(),
),
SaveFileIntent: CallbackAction<SaveFileIntent>(
onInvoke: (_) => onSave(),
),
QuitAppIntent: CallbackAction<QuitAppIntent>(
onInvoke: (_) => onQuit(),
),
},
child: Focus(
autofocus: true,
child: Container(),
),
),
);
}
}
class OpenFileIntent extends Intent {
const OpenFileIntent();
}
class SaveFileIntent extends Intent {
const SaveFileIntent();
}
class QuitAppIntent extends Intent {
const QuitAppIntent();
}
Step 6: マウス右クリックメニュー
コンテキストメニューの実装
Cursorに以下をリクエスト:
右クリックでコンテキストメニューを表示する機能を実装してください。
メニュー項目: コピー、ペースト、削除
生成されるコード例:
class ContextMenuExample extends StatelessWidget {
const ContextMenuExample({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapDown: (details) {
_showContextMenu(context, details.globalPosition);
},
child: Container(
padding: const EdgeInsets.all(20),
child: const Text('右クリックしてメニューを表示'),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final result = await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: [
const PopupMenuItem(
value: 'copy',
child: Row(
children: [
Icon(Icons.copy, size: 18),
SizedBox(width: 8),
Text('コピー'),
],
),
),
const PopupMenuItem(
value: 'paste',
child: Row(
children: [
Icon(Icons.paste, size: 18),
SizedBox(width: 8),
Text('ペースト'),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18, color: Colors.red),
SizedBox(width: 8),
Text('削除', style: TextStyle(color: Colors.red)),
],
),
),
],
);
if (result != null) {
print('選択: $result');
}
}
}
Step 7: アプリのビルドと配布
Windows向けビルド
# 開発版ビルド
flutter build windows
# リリース版ビルド(最適化)
flutter build windows --release
# 生成物: build/windows/runner/Release/
# 配布時は、Releaseフォルダ全体をZIPで配布
macOS向けビルド
# ビルド
flutter build macos --release
# 生成物: build/macos/Build/Products/Release/
# .appファイルをDMGにパッケージングして配布
Linux向けビルド
# ビルド
flutter build linux --release
# 生成物: build/linux/x64/release/bundle/
# bundleフォルダをtarで圧縮して配布、または.debパッケージを作成
Step 8: インストーラーの作成(オプション)
Windows: Inno Setupを使用
Cursorに以下をリクエスト:
Inno Setupスクリプトのテンプレートを作成してください。
アプリ名: My Desktop App
実行ファイル: my_desktop_app.exe
macOS: create-dmgを使用
# Homebrewでcreate-dmgをインストール
brew install create-dmg
# DMGを作成
create-dmg \
--volname "My Desktop App" \
--window-pos 200 120 \
--window-size 800 400 \
MyDesktopApp.dmg \
build/macos/Build/Products/Release/my_desktop_app.app
まとめ
学んだこと
✅ ウィンドウ管理: サイズ、位置、タイトルのカスタマイズ ✅ デスクトップレイアウト: サイドバー、ステータスバー ✅ ファイル操作: ファイルピッカー、読み書き ✅ ショートカット: キーボードショートカットの実装 ✅ コンテキストメニュー: 右クリックメニュー ✅ ビルドと配布: 各プラットフォーム向けのビルド方法
デスクトップアプリ開発のベストプラクティス
- レスポンシブ対応: 最小サイズを設定し、リサイズに対応
- キーボード優先: ショートカットキーを充実させる
- ネイティブUI: プラットフォームの慣習に従う
- ファイル管理: 適切なディレクトリに保存
- エラーハンドリング: ファイル操作は必ずtry-catch
次のステップ
デスクトップアプリの知識を活用して、次はWebアプリに挑戦しましょう:
➡️ 次のレシピへ: #5-6: Webアプリを作る