Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Vaporを活用しMacとiPhone間で双方向通信を行う

iOSチームの福山です。 Mirrativ iOSでは Flipper (Meta社のOSS) を使っていました。しかし Xcode 16.3 から Flipper が動かなくなるという事象が発生し、リポジトリもアーカイブされていることから、Vapor を利用して代替となる機能を作成しようと考えました。今回の記事では、その基礎となるサンプルの実装をご紹介します。

VaporはSwift言語で書かれたオープンソースのWebフレームワークです。

サンプル作成のフローは次のようになります。

  • VaporでMac上のWebブラウザにWebページを表示するローカルサーバーをたてる
  • VaporでWebSocket(双方向通信)を行うローカルサーバーをたてる
  • VaporとiPhone上のアプリがWebSocketで双方向通信
  • VaporとWebページがWebSocketで双方向通信

Vaporの導入

HomebrewでVaporをインストールします。 Vaporアプリの名前はMyVaporAppとしておきます。

brew install vapor
vapor --version # ブログ記事の時点では19.2.0
vapor new MyVaporApp

以下のように、いくつか質問されます。今回はORMもLeafも使わないので、2回とも "n" を入力し Enter (or Return) キーを押しセットアップを完了させます。

Cloning template...
Would you like to use Fluent (ORM)? (--fluent/--no-fluent)
y/n> n

fluent: No
Would you like to use Leaf (templating)? (--leaf/--no-leaf)
y/n> n

leaf: No
Generating project files
Creating git repository
Adding first commit
Project MyVaporApp has been created!
Use cd 'MyVaporApp' to enter the project directory
Then open your project, for example if using Xcode type open Package.swift or code . if using VSCode

Vaporの実装

まずWebページとなる MyVaporApp/Public/index.html を追加します。

<html>
<head>
    <title>My Vapor App</title>
    <style>
        #send-button {
            margin: 20px;
        }
        #content {
            width: 500px;
            min-height:50px;
            border: 1px solid blue;
        }
    </style>
</head>
<body>
    <button id="send-button">iPhoneアプリへ何か送信</button>
    <hr>

    <p>iPhoneアプリから来たメッセージ</p>
    <div id="content"></div>

    <script src="script.js"></script>
</body>
</html>

次にWebページとVaporをWebSocketで接続するため MyVaporApp/Public/script.js を追加します。

// WebSocket
(() => {
    const socket = new WebSocket('ws://localhost:8080/ws-interface')

    // メッセージ受信時の処理
    const content = document.getElementById('content')
    socket.onmessage = function (event) {
        const data = JSON.parse(event.data)
        let html = `<p>${data.message}</p>`
        content.insertAdjacentHTML('beforeend', html)
    }

    // エラー処理
    socket.onerror = function (error) {
        console.error('WebSocket Error:', error)
    }

    // 接続が閉じられたときの処理
    socket.onclose = function (event) {
        console.log('WebSocket connection closed')
    }

    // アプリへメッセージを送る
    const sendButton = document.getElementById('send-button')
    sendButton.addEventListener('click', function () {
        const randomNumberString = Math.random().toString()
        const json = {
            message: randomNumberString
        }
        let jsonString = JSON.stringify(json)
        socket.send(jsonString)
    })
})();

最後にMyVaporApp/Sources/MyVaporApp/routes.swift を開き以下のコードに置き換えます。

import Vapor

func routes(_ app: Application) throws {
    // iPhone など同一LANから到達できるよう全I/Fで待受け
    app.http.server.configuration.hostname = "0.0.0.0"
    app.http.server.configuration.port = 8080

    // Publicフォルダを使用可能にする (index.html をデフォルトに)
    let fileMiddleware = FileMiddleware(
        publicDirectory: app.directory.publicDirectory,
        defaultFile: "index.html"
    )
    app.middleware.use(fileMiddleware)

    let webSocketStore = WebSocketStore()

    // WebページのJSから接続
    // ws://localhost:8080/ws-interface
    app.webSocket("ws-interface") { req, ws in
        webSocketStore.interface = ws

        ws.onText { ws, text in
            webSocketStore.clients.forEach({
                $0.object?.send(text)
            })
        }
    }

    // iPhoneアプリから接続
    // ws://192.168.x.x:8080/ws-client
    app.webSocket("ws-client") { req, ws in
        webSocketStore.appendClient(ws)

        ws.onText { ws, text in
            webSocketStore.interface?.send(text)
        }

        ws.onClose.whenComplete { result in
            print("Client WebSocket closed: \(result)")
            webSocketStore.removeClient(ws)
        }
    }
}

final class WebSocketStore: @unchecked Sendable {
    var interface: WebSocket?
    var clients: [WeakObjectWrapper] = []

    func appendClient(_ client: WebSocket) {
        clients.append(WeakObjectWrapper(object: client))
    }

    func removeClient(_ client: WebSocket) {
        clients.removeAll(where: { $0.object === client })
    }
}

final class WeakObjectWrapper {
    weak var object: WebSocket?

    init(object: WebSocket) {
        self.object = object
    }
}

以上でVapor側は完了です。

iOSアプリの実装

XcodeでSwiftUIのプロジェクトを新規作成するとContentView.swift が生成されています。 このファイルを以下に書き換えます。

import Observation
import SwiftUI

struct ContentView: View {
    @State private var state = ContentViewState()

    var body: some View {
        VStack {
            Button {
                state.sendSomeMessage()
            } label: {
                Text("Webページへ何か送信")
                    .foregroundStyle(Color.white)
            }
            .padding(.vertical, 10)
            .padding(.horizontal, 20)
            .background(
                RoundedRectangle(cornerRadius: 10, style: .continuous)
                    .foregroundStyle(Color.cyan)
            )

            Divider()
                .padding(.bottom, 10)

            Text("Webページから来たメッセージ")
            Text(state.message)
                .foregroundStyle(Color.gray)
        }
        .padding()
    }
}

@Observable
final class ContentViewState {
    protocol Delegate: AnyObject {
        func didReceiveMessage(_ message: Message)
    }

    var message = ""

    init() {
        VaporService.shared.contentViewStateDelegate = self
    }

    func sendSomeMessage() {
        let message = Int.random(in: 1...10000).description
        VaporService.shared.sendMessage(Message(message: message))
    }
}

extension ContentViewState: ContentViewState.Delegate {
    func didReceiveMessage(_ message: Message) {
        let text = message.message
        self.message += self.message.isEmpty ? text : "\n\(text)"
    }
}

#Preview {
    ContentView()
}

struct Message: Codable {
    let message: String
}

final class VaporService: NSObject {
    static let shared = VaporService()

    weak var contentViewStateDelegate: ContentViewState.Delegate?

    private lazy var urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil)

    /// 192.168.10.5部分はMacのローカルIPで環境や人によって異なる。
    /// MacとiPhoneが同一WiFiに繋がっていることが前提で
    /// `ipconfig getifaddr en0` コマンドやMacのWiFi詳細設定から確認できる。
    private var url: URL? = URL(string: "ws://192.168.10.5:8080/ws-client")

    private(set) var isOpen = false

    private var webSocketTask: URLSessionWebSocketTask? {
        didSet {
            oldValue?.cancel()
        }
    }

    private var receiveMessageTask: Task<Void, Never>? {
        didSet {
            oldValue?.cancel()
        }
    }

    override private init() {
        super.init()
        start()
    }

    func start() {
        guard let url else {
            return
        }
        webSocketTask = urlSession.webSocketTask(with: url)
        webSocketTask?.delegate = self
        webSocketTask?.resume()
    }

    func receive() async {
        guard let webSocketTask else { return }
        while isOpen {
            do {
                let message = try await webSocketTask.receive()
                switch message {
                case let .string(text):
                    guard let data = text.data(using: .utf8) else { return }
                    let decodedMessage: Message = try JSONDecoder().decode(Message.self, from: data)
                    contentViewStateDelegate?.didReceiveMessage(decodedMessage)
                default:
                    break
                }
            } catch {
                closeWebSocket()
                isOpen = false
            }
        }
    }

    func closeWebSocket() {
        webSocketTask?.cancel(with: .goingAway, reason: nil)
    }

    /// Webページへメッセージを送る
    func sendMessage(_ message: Message) {
        guard isOpen else { return }
        Task {
            let encoded = try JSONEncoder().encode(message)
            if let jsonString = String(data: encoded, encoding: .utf8) {
                try? await webSocketTask?.send(.string(jsonString))
            }
        }
    }
}

extension VaporService: URLSessionWebSocketDelegate {
    public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        isOpen = true
        receiveMessageTask = Task {
            await receive()
        }
    }

    public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        isOpen = false
    }
}

iPhoneとMacが同じWiFi下にあることを前提として、iPhoneからMacに接続するためにMacのローカルIPアドレスが必要になります。 通常はipconfig getifaddr en0コマンド等で取得可能で、MacのWiFi詳細設定からも確認できます。 ここでは触れませんが、自動化をするとしたらXcodeでビルド時にBuild PhaseでIPアドレスを取得しinfo.plistに保存しランタイムに取り出して使うという方法があります。

接続

まずはVaporを起動します。

# 作成したVaporフォルダへ移動
cd path/to/MyVaporApp
# 起動
swift run

# 以下出力
[1/1] Planning build
Building for debugging...
[1/1] Write swift-version--XXXXXXXXXXXXXXXX.txt
Build of product 'MyVaporApp' complete! (4.22s)
[ NOTICE ] Server started on http://0.0.0.0:8080

Vapor起動時にネットワーク受信接続の許諾ダイアログが表示されるので許可してください。

次にWebブラウザで http://localhost:8080 を開くとターミナルにはこのように出力されます。

[ INFO ] GET / [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]
[ INFO ] GET /script.js [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]
[ INFO ] GET /favicon.ico [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]
[ INFO ] GET /ws-interface [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]

次にiOSアプリをXcodeでビルドします。 ローカルネットワーク接続の許諾ダイアログが表示されるので許可します。

再度ビルドするかアプリをキルして開きなおすとターミナルには以下のように出力されます。

[ INFO ] GET /ws-client [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]

ここまでの操作で接続は完了しました。 WebページとiOSアプリの一方でボタンを押すともう一方にランダムな数字が表示されるようになり、開発環境で双方向通信が実現できました。

おわりに

今回は最低限のサンプルをご紹介しました。以下の画像のようにMirrativ iOSではUnityやPub/Sub通信等のログ取得と操作で活用しています。アイデア次第で様々な使い方ができるのではないでしょうか。

We are hiring!

ミラティブでは一緒に開発してくれる iOS エンジニアを募集しています! 少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、お気軽にご連絡ください。

hrmos.co

mirrativ.notion.site

インターンも募集中です!

hrmos.co