Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Flutter デスクトップアプリで遊んでみたら意外となんでもできた

Avatar for Tasuku Maeda Tasuku Maeda
May 16, 2026
0

Flutter デスクトップアプリで遊んでみたら意外となんでもできた

2026/05/11開催の「AI時代のFlutter開発スペシャル」
で発表した内容です。

Avatar for Tasuku Maeda

Tasuku Maeda

May 16, 2026

Transcript

  1. 今日話すこと パート 時間 内容 デモ1 8分 Flutter Desktop でためした機能を紹介 デモ2

    3分 機能を組み合わせた画像ビューア アプリ デモ3 6分 VSCode拡張 × Claude × VOICEVOX × Flame でずんだもん解説を生成 ※ ここだけFlutter Webです ※ デモ1はソースコード公開予定です 4
  2. 初期設定(mac用) # macOS 用プロジェクトを作成 fvm flutter create --platforms=macos myapp cd

    myapp Flutter SDK管理にふだんfvmを使っているのでfvmで書いてますが、もちろんfvmな しでも大丈夫です! iOS / Android 開発と同じ感覚で始められる 7
  3. Debug & Release # Debug 起動(ホットリロード可) fvm flutter run -d

    macos # Release ビルド fvm flutter build macos --release # → build/macos/Build/Products/Release/myapp.app Debug は ホットリロード可 で開発中はこっち Release ビルドは .app バンドル ができ、Finder でダブルクリック可能 Debug と Release では挙動が違う場面がある(デモ1 のハマりポイントで詳し く…) 8
  4. デモ1: 紹介する機能(7つ) 1. Window — ウィンドウの操作・制御 2. System Tray —

    メニューバーに常駐 3. Drag & Drop — ファイルを D&D で受け取る 4. File Explorer — ローカルファイルを閲覧 5. Local Server — HTTP サーバーを起動 6. curl — HTTP リクエストを実行 7. Claude — Claude CLI を呼び出す 10
  5. デモ動画 ① UI・ファイル系 Window / System Tray / Drag &

    Drop / File Explorer https://youtu.be/0UcUY6jr2Lc 11
  6. 1. Window — 実装 window_manager ライブラリを使用 // ウィンドウ操作 await windowManager.setSize(const

    Size(800, 600)); await windowManager.setAlwaysOnTop(true); await windowManager.setOpacity(0.8); // 「新しいウィンドウ」ではなく、自プロセスをもう1つ起動 // これはwindow_managerではなくdart.ioの機能 await Process.start(Platform.resolvedExecutable, []); 13
  7. 1. Window — ハマりどころ main() で await windowManager.ensureInitialized() を必ず先に呼ぶ runAppの前

    window_managerだと複数ウインドウの管理はできない アプリをもうひとつ起動する形 Debugだと新しいウインドウは作れなかった 別ウィンドウで状態共有したいなら desktop_multi_window 等の本格パッケージ へ 14
  8. 2. System Tray — 実装 tray_manager ライブラリを使用 class _State extends

    State<MyPage> with TrayListener { await trayManager.setIcon('assets/tray_icon.png'); // Flutter assets パスを渡す await trayManager.setContextMenu(Menu(items: [ MenuItem(label: 'あいさつ', key: 'greet'), MenuItem(label: 'モード', submenu: Menu(items: [...])), ])); @override void onTrayIconMouseDown() => trayManager.popUpContextMenu(); } 16
  9. 2. System Tray — ハマりどころ(Directory.current 罠) ${Directory.current.path}/... で絶対パス組立 → Debug

    は通るが Release (Finder起動)でうまくいかない( Directory.current が起動方法で変わる) tray_manager の macOS 実装は内部で rootBundle.load(iconPath) を呼ぶ → アセット相対パス( assets/xxx.png )を渡すのが正解 pubspec.yaml の flutter.assets に PNG を登録する必要あり 17
  10. 3. Drag & Drop ファイル / ディレクトリを D&D で受 け取る

    複数ファイル同時、ドラッグ中の hover フィードバック対応 18
  11. 3. Drag & Drop — 実装 desktop_drop DropTarget( onDragEntered: (_)

    => setState(() => _isDragging = true), onDragExited: (_) => setState(() => _isDragging = false), onDragDone: (DropDoneDetails details) { for (final xFile in details.files) { final file = File(xFile.path); // 絶対パスが取れる // ... } }, child: /* ドロップエリア */, ) 19
  12. 3. Drag & Drop — ハマりどころ ディレクトリも XFile で渡る →

    stat.type == FileSystemEntityType.directory で判定 macOS Sandboxing でファイルアクセス権限の Entitlement が必要 20
  13. 4. File Explorer — 実装 dart:io (一覧・読込) highlight (パース)+ flutter_highlight

    (Monokai テーマだけ拝借) // ディレクトリ一覧(sortでフォルダ優先で名前順ソートなどできる) final entries = Directory(path).listSync() ..sort((a, b) { /* ... */ }); // ファイル読込 final content = await File(path).readAsString(); // シンタックスハイライト(選択コピーも効かせるため自前で TextSpan に) final result = highlight.parse(content, language: lang); return SelectableText.rich(_toSpans(result.nodes, theme)); 22
  14. 4. File Explorer — ハマりどころ flutter_highlight の HighlightView だと表示は簡単。でも選択できない。 (

    SelectionArea で囲んでも 選択コピー不可) → 自前で SelectableText.rich を組む( highlight パッケージで AST 取得 → TextSpan に変換) 23
  15. 5. Local Server — 実装 dart:io の HttpServer (追加パッケージ不要!) final

    server = await HttpServer.bind( InternetAddress.loopbackIPv4, 8080, ); server.listen((HttpRequest request) { request.response ..headers.contentType = ContentType.json ..write(jsonEncode({'message': 'Hello from Flutter'})) ..close(); }); 実はiOS/AndroidでもHTTPサーバーは立てられる。でも外部からアクセスしづらい デスクトップアプリならブラウザからもアクセス可能 26
  16. 5. Local Server — ハマりどころ App Store 配布で app-sandbox: true

    にする場合のみ Release.entitlements に com.apple.security.network.server の追加が必要 開発時は何もしなくて OK(Flutter テンプレートのデフォルトで app-sandbox: false のため Entitlement の制限を受けない) dispose() で必ず server.close() 。やらないとポートを掴みっぱなし 27
  17. 6. curl — 実装 dart:io の Process.run final result =

    await Process.run('curl', ['-s', url]); final body = result.stdout as String; 29
  18. 6. curl — ハマりどころ curl は /usr/bin/curl にあるので、コマンドが動作しないといった PATH 問題

    は起きない(次の Claude では起きる…) 開発時は何もしなくて OK( app-sandbox: false のため) 。App Store 配布時のみ network.client の Entitlement が必要 30
  19. 7. Claude CLI連携 claude -p を呼び出し Claude のレスポンスを Markdown で

    レンダリング Flutter から AI を呼べる! 31
  20. 7. Claude CLI連携 — 実装 dart:io の Process.run + flutter_markdown_plus

    ライブラリ(表示) // Finder ダブルクリック起動でも PATH を解決するため zsh -ilc 経由 final result = await Process.run( '/bin/zsh', ['-ilc', 'claude -p "\$1"', 'flutter-app', prompt], ); // Claude の応答(Markdown)をレンダリング return Markdown( data: result.stdout as String, selectable: true, // 選択コピー対応 styleSheet: MarkdownStyleSheet(/* h1/code 等のスタイル指定 */), ); 32
  21. 7. Claude CLI連携 — ハマりどころ Release を Finder からダブルクリック起動 すると

    claude が PATH に無く、アプ リごと落ちる(なぜかcatch も素通り) /bin/zsh -ilc でログイン + インタラクティブシェル経由で呼ぶ。 -i を付けな いと ~/.zshrc が読まれず PATH が補強されない シェルエスケープ回避のため、プロンプトは "$1" 展開で渡す 33
  22. デモ2: 画像ビューア 入力: D&D / macOSのメニューから NSOpenPanel 対応形式: PNG /

    JPEG / GIF / WebP / ZIP 並び順: 名前順 / 更新日時 表示: 全体 / 画面いっぱい / 実寸 shared_preferences で設定と最終 ソースを永続化(次回起動時に自動 復元) 36
  23. デモ2: 実装ポイント 実用アプリにするにあたって、こだわった所: 1. ファイル選択 — MethodChannel で NSOpenPanel を直接叩く(ファイル

    / ディレ クトリを 1 ダイアログで両対応) 2. マウスホイール対応 — スクロール / ピンチ操作 3. キーボードショートカット対応 — ページ送り / 表示モード切替などをキー1つで 4. 画像 ZIP 対応 — archive パッケージで ZIP 内画像を直接 ImageProvider 化 → それぞれ別ページで実装の中身を見ていきます 37
  24. 実装① ファイル選択(NSOpenPanel) // Swift (macos/Runner/MainFlutterWindow.swift) let panel = NSOpenPanel() panel.canChooseFiles

    = true panel.canChooseDirectories = true // ← ファイル「も」ディレクトリ「も」OK panel.allowsMultipleSelection = true panel.allowedFileTypes = ["png","jpg","jpeg","gif","webp","zip"] panel.beginSheetModal(for: self) { _ in result(panel.urls.map { $0.path }) } // Dart (data/services/file_dialog_service.dart) final paths = await MethodChannel('image_viewer/file_dialog') .invokeListMethod<String>('openPaths'); file_picker ライブラリだとファイル/ディレクトリで別ダイアログになる NSOpenPanel を直接叩くと 1 つのダイアログで両対応にできる 38
  25. 実装② マウスホイール対応 Flutter 標準の Listener + PointerScrollEvent // viewer_page.dart Listener(

    onPointerSignal: (event) { if (event is! PointerScrollEvent) return; if (event.scrollDelta.dy > 0) { notifier.showNext(); // ↓ スクロール = 次の画像 } else if (event.scrollDelta.dy < 0) { notifier.showPrevious(); // ↑ スクロール = 前の画像 } }, child: ..., ) 39
  26. 実装③ キーボードショートカット 2系統 で実装: // ① menus/app_menu_bar.dart — macOS メニューと連動(メニューに

    ⌘O が表示される) PlatformMenuBar(menus: [ PlatformMenu(label: 'ファイル', menus: [ PlatformMenuItem( label: '開く...', shortcut: const SingleActivator(LogicalKeyboardKey.keyO, meta: true), onSelected: notifier.openFromMenu, ), ]), ]); // ② widgets/viewer_page.dart — メニューに出さないキーは Focus で直接処理 Focus(onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { notifier.showPrevious(); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.arrowRight) { notifier.showNext(); return KeyEventResult.handled; } // = / - / 0 でズーム操作 return KeyEventResult.ignored; }, child: ...); 40
  27. 実装④ 画像 ZIP 対応 archive パッケージ + 自前の ImageProvider //

    ZIP を開く(中身の解凍は遅延、find したものだけ展開される) final input = InputFileStream(zipPath); final archive = ZipDecoder().decodeStream(input); Uint8List readBytes(String entryName) => archive.find(entryName)!.content; // ImageProvider を継承して、ZIP 内のエントリを画像として返す class ArchiveImageProvider extends ImageProvider<ArchiveImageProvider> { Future<Codec> _loadAsync(...) async { final bytes = source.readBytes(entryName); // ZIP から取り出す final buffer = await ImmutableBuffer.fromUint8List(bytes); // メモリへ return decode(buffer); // → Flutter の Image へ } } 41
  28. デモ動画 VSCode拡張 × ずんだもん解説 https://youtu.be/3qwrJnNTdg8 操作フロー: 1. 適当なコードを範囲選択(左下にチップが自動表示) 2. 質問を入力

    → 「解説して!」 ボタン 3. ずんだもんが進捗を実況: 「Claude さんに依頼中なのだ ...」 「合成中 1/8 ...」 4. 「準備できたのだ〜!画面をクリックで再生開始するのだ!▼」 で告知 5. ステージクリック → 字幕 + 口パク + 音声同期で会話劇スタート 43
  29. デモ3: 何をしているの? VSCode の 下パネル に解説タブを追 加 コードを範囲選択 → 質問を書いて

    「解説して!」 ボタン ずんだもん × 四国めたんが 掛け合い で解説(音声合成 + 字幕 + 口パクを リアルタイムで合成) プロジェクトの CLAUDE.md も Claude に読まれる → コードベースに 即した解説 44
  30. VSCode 拡張機能はどう作る? 本体(拡張ホスト): Node.js / TypeScript で動く VSCode の API(エディタ操作、コマンド登録、選択範囲取得など)を呼べる

    GUI: WebView で HTML / JS / CSS を表示するのが標準 拡張ホスト WebView は postMessage で双方向通信 → 今回は WebView の中身を Flutter Web で作った flutter build web の成果物を WebView に読ませる 普段の Flutter 開発と同じ感覚で UI を組める! 45
  31. VOICEVOX とは 無料で使える テキスト音声合成エンジン ローカル PC で動く(オフライン・無料) ずんだもん / 四国めたん

    など 30+ 種類のキャラ音声 ローカル HTTP API( http://127.0.0.1:50021 )で操作 # テキストからクエリ生成 POST /audio_query?text=こんにちは&speaker=3 # クエリから音声合成(WAV 取得) POST /synthesis?speaker=3 公式: https://voicevox.hiroshiba.jp/ 46
  32. Flame ライブラリとは Flutter 用の 2D ゲームエンジン スプライトアニメーション / 衝突判定 /

    シーン管理 Component ベース の設計( FlameGame に Component を追加していく) update(dt) で 各フレームごとの処理 を書ける ゲームに限らず タイミング制御が必要なアニメ に向く 公式: https://flame-engine.org/ → デモ3 では 口パクアニメーション と 字幕の文字送り に使用 47
  33. デモ3: 仕組み(パイプライン) 1. VSCode 拡張でエディタ選択 + プロンプトを取得 2. claude -p

    --output-format json で シナリオ JSON を生成 [{speaker: 'zundamon', text: '...'}, {speaker: 'metan', text: '...'}, ...] cwd を workspace folder に設定 → プロジェクトの CLAUDE.md を引き継ぎ 3. VOICEVOX のローカル API で各セリフを WAV 化 → /connect_waves で 1 本に連 結 4. Flutter Web (Flame) が WebView で再生 AudioPlayer 1 個 + seek(offsetMs) でセリフ切替 字幕送り( durationMs / textLength )+ 話者で口パク切替 49
  34. デモ3: ハマり① ブラウザの autoplay policy 最初は「セリフごとに AudioPlayer を作って play() を順次呼ぶ」方式だったが…

    2 セリフ目以降が無音(字幕は進むのに音だけ止まる) 原因: ブラウザは「ユーザー操作と紐付いた <audio> 要素」しか自動再生を許可しな い。 audioplayers の Web 実装は AudioPlayer ごとに <audio> を作るので、2 個目 以降は別要素扱いでブロック。 解決: VOICEVOX の /connect_waves で全セリフを 1 本の WAV に連結 → AudioPlayer 1 個固定 + seek で位置切替。最初のクリックで取った再生権限を全セ リフ通して使い回せる。 50
  35. デモ3: ハマり② WebView の音声 URL と CSP 合成した WAV を

    WebView から再生するには 2つの壁 がある: 1. localResourceRoots に書き出し先を追加 webview.options = { localResourceRoots: [flutterRoot, audioRoot], // ← audioRoot を追加 }; const url = webview.asWebviewUri(vscode.Uri.file(wavPath)).toString(); 2. CSP に media-src を追加(忘れると <audio> が黙殺) media-src ${webview.cspSource} blob: 書き出し先は context.storageUri (workspace storage)を使う。 51
  36. デモ3: Flame で口パク・字幕送り flame (ゲームエンジン本体) audioplayers (音声再生、独立パッケージ。 flame_audio は今回使わず) //

    セットアップは 1 回だけ final player = AudioPlayer(); await player.setSource(UrlSource(audioUrl)); // 各セリフ: seek → resume → durationMs 待つ → pause await player.seek(Duration(milliseconds: entry.offsetMs)); await player.resume(); await Future.delayed(Duration(milliseconds: entry.durationMs)); await player.pause(); 口パク: SpriteAnimationComponent.playing = true/false で話者切替 字幕送り: update(dt) で durationMs / textLength の速度で文字を進める 53