# macOS での利用方法

## 概要

Sora Flutter SDK で macOS アプリケーションを実装する際のカメラやマイクの権限設定、注意点について説明します。
対応バージョンは macOS 15 以降、Apple Silicon です。
Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。


## 権限

macOS で音声や映像を配信する場合は、配信するメディアに応じてカメラまたはマイクの権限が必要になります。
音声のみの場合はマイク、映像のみの場合はカメラだけで十分です。
SDK は権限リクエストを行わないため、アプリケーション側で `Info.plist` の用途説明と Entitlements への設定、およびネイティブ側の権限リクエストと Dart 側からの呼び出しを実装してください。

### Info.plist の用途説明

```xml
<key>NSCameraUsageDescription</key>
<string>カメラを利用する目的を記述します</string>
<key>NSMicrophoneUsageDescription</key>
<string>マイクを利用する目的を記述します</string>
```

### Entitlements の設定

App Sandbox を有効にしている場合は、Entitlement に配信するメディアに応じた設定をしてください。
カメラ送信には `com.apple.security.device.camera` 、マイク送信には `com.apple.security.device.audio-input` が必要です。
`DebugProfile.entitlements` と `Release.entitlements` の両方に設定する必要があります。

```xml
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
```

また、 Sora への接続には `com.apple.security.network.client` が必要です。

```xml
<key>com.apple.security.network.client</key>
<true/>
```

> **注釈**
>
> 視聴のみ( `recvonly` ロール)で利用する場合はカメラ・マイクを使わないため、デバイス関連の Entitlement は不要です。

### ランタイム権限のリクエスト

ランタイム権限の取得は、Dart 側から `MethodChannel` 経由でプラットフォームの権限 API を呼び出す構成が基本です。

#### Dart 側の実装例（全プラットフォーム共通）

Dart 側の `ensureMediaPermissions()` は `MethodChannel` に `camera` / `microphone` の要否を渡すラッパーです。
この呼び出しは `Android` / `iOS` / `macOS` で共通で利用することができます。

```dart
import 'dart:io';

import 'package:flutter/services.dart';

/// プラットフォームのメディア権限（カメラ・マイク）を要求します。
///
/// ``MethodChannel`` 経由で各プラットフォームのネイティブ権限 API を呼び出します。
/// この Dart コードは ``Android`` / ``iOS`` / ``macOS`` で共通です。
///
/// プラットフォームが ``Android`` / ``iOS`` / ``macOS`` 以外の場合は
/// 常に ``true`` を返します。
///
/// ネイティブ側では同じチャンネル名 ``"example/permissions"`` (チャンネル名は例)で
/// ``ensureMediaPermissions`` メソッドをハンドリングする必要があります。
///
/// - Android: ``ActivityCompat.requestPermissions`` で
///   ``CAMERA`` / ``RECORD_AUDIO`` を要求
/// - iOS / macOS: ``AVCaptureDevice.requestAccess`` で
///   ``.video`` / ``.audio`` を要求
///
/// [needsCamera]: カメラ権限が必要な場合に ``true``
/// [needsMicrophone]: マイク権限が必要な場合に ``true``
///
/// 戻り値: すべての要求された権限が許可された場合に ``true``
Future<bool> ensureMediaPermissions({
  required bool needsCamera,
  required bool needsMicrophone,
}) async {
  // Android / iOS / macOS 以外では権限要求の仕組みが異なるため
  // 常に許可扱いとします。
  if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) {
    return true;
  }

  // MethodChannel 経由でネイティブ側の権限 API を呼び出します。
  // チャンネル名 "example/permissions" はネイティブ側と一致させる必要があります。
  // (実際の実装時はアプリケーション名等に合わせて命名してください)
  const channel = MethodChannel('example/permissions');

  // 戻り値が null の場合は false として扱います。
  return await channel.invokeMethod<bool>(
        'ensureMediaPermissions',
        <String, Object?>{
          'camera': needsCamera,
          'microphone': needsMicrophone,
        },
      ) ??
      false;
}
```

接続前の呼び出し例:

```dart
final granted = await ensureMediaPermissions(
  needsCamera: true,
  needsMicrophone: true,
);
if (!granted) {
  return;
}
final stream = await MediaDevices.getUserMedia(
  const GetUserMediaOptions(audio: true, video: true),
);
final conn = await Sora.createConnection(
  SoraConnectionConfig(
    signalingUrls: <String>['wss://sora.example.com/signaling'],
    channelId: 'example-channel',
    role: SoraRole.sendrecv,
    audio: true,
    video: true,
  ),
);
await conn.connect(stream);
```

> **注釈**
>
> `permission_handler` パッケージを利用する方法もありますが、 Sora Flutter SDK の DevTools ではプラットフォーム側の細かい制御と依存の最小化のため、
> 独自の `MethodChannel` を採用しています。

#### macOS ネイティブ側の実装

`MainFlutterWindow.swift` で `MethodChannel` のハンドラを設定し、
`AVCaptureDevice.requestAccess` でカメラ・マイクの許可を要求します。
macOS では `Info.plist` に用途説明が設定されていても、初回アクセス時に OS の権限ダイアログが表示されます。

```swift
import AVFoundation
import Cocoa
import FlutterMacOS

private final class PermissionHandler: NSObject {
    private let channel: FlutterMethodChannel

    init(binaryMessenger: FlutterBinaryMessenger) {
        channel = FlutterMethodChannel(
             name: "example/permissions",
            binaryMessenger: binaryMessenger
        )
        super.init()
        channel.setMethodCallHandler(handle)
    }

    private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "ensureMediaPermissions":
            let args = call.arguments as? [String: Any] ?? [:]
            let needsCamera = (args["camera"] as? Bool) ?? false
            let needsMicrophone = (args["microphone"] as? Bool) ?? false
            ensureMediaPermissions(
                needsCamera: needsCamera,
                needsMicrophone: needsMicrophone,
                result: result
            )
        default:
            result(FlutterMethodNotImplemented)
        }
    }

    private func ensureMediaPermissions(
        needsCamera: Bool,
        needsMicrophone: Bool,
        result: @escaping FlutterResult
    ) {
        requestCameraIfNeeded(needsCamera) { cameraGranted in
            if !cameraGranted {
                result(false)
                return
            }
            self.requestMicrophoneIfNeeded(needsMicrophone) { microphoneGranted in
                result(microphoneGranted)
            }
        }
    }

    private func requestCameraIfNeeded(
        _ needsCamera: Bool,
        completion: @escaping (Bool) -> Void
    ) {
        guard needsCamera else {
            completion(true)
            return
        }
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .authorized:
            completion(true)
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { granted in
                DispatchQueue.main.async {
                    completion(granted)
                }
            }
        case .denied, .restricted:
            completion(false)
        @unknown default:
            completion(false)
        }
    }

    private func requestMicrophoneIfNeeded(
        _ needsMicrophone: Bool,
        completion: @escaping (Bool) -> Void
    ) {
        guard needsMicrophone else {
            completion(true)
            return
        }
        let status = AVCaptureDevice.authorizationStatus(for: .audio)
        switch status {
        case .authorized:
            completion(true)
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .audio) { granted in
                DispatchQueue.main.async {
                    completion(granted)
                }
            }
        case .denied, .restricted:
            completion(false)
        @unknown default:
            completion(false)
        }
    }
}

class MainFlutterWindow: NSWindow {
    private var permissionHandler: PermissionHandler?

    override func awakeFromNib() {
        let flutterViewController = FlutterViewController()
        let windowFrame = self.frame
        self.contentViewController = flutterViewController
        self.setFrame(windowFrame, display: true)

        RegisterGeneratedPlugins(registry: flutterViewController)
        permissionHandler = PermissionHandler(
            binaryMessenger: flutterViewController.engine.binaryMessenger
        )

        super.awakeFromNib()
    }
}
```

#### 実装の参照先

devtools の実装全体は以下を参照してください。

- Dart: `devtools/lib/src/devtools_connection_controller.dart` ( `ensureMediaPermissions` )
- macOS ネイティブ: `devtools/macos/Runner/MainFlutterWindow.swift`

## その他

- リリースビルドでは縮小や難読化の影響を受けるため、 `release` でも必ず起動・動作確認をしてください
- カメラやマイクを使う場合は、ビルド前に `Info.plist` の用途説明と Entitlements への設定を必ず行ってください
- アプリケーション側で CocoaPods や Xcode 設定を独自に変更する場合は、SDK 側が前提にしているリンク設定を壊さないように注意してください
