macOS での利用方法

概要

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

権限

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

Info.plist の用途説明

<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 の両方に設定する必要があります。

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

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

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

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

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

Dart 側の実装例(全プラットフォーム共通)

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

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;
}

接続前の呼び出し例:

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);

macOS ネイティブ側の実装

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

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 側が前提にしているリンク設定を壊さないように注意してください