-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ios): 로컬 ZeroConf 플러그인 — 실기기 Bonjour 활성화 #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
44 changes: 44 additions & 0 deletions
44
docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # iOS 로컬 ZeroConf 플러그인 설계 (실기기 Bonjour 활성화) | ||
|
|
||
| - 날짜: 2026-08-05 | ||
| - 상태: 확정 (2026-07 잔여 리스크 항목 — capacitor-zeroconf SPM 미지원 대응) | ||
| - 범위: `ios/App/App/`(Swift 2파일 + storyboard 클래스 변경) + docs. JS 무변경. | ||
|
|
||
| ## 0. 문제 | ||
|
|
||
| capacitor-zeroconf 4.0.0은 Package.swift가 없어 SPM 빌드에 네이티브가 | ||
| 미탑재 → 실기기에서 `Capacitor.isPluginAvailable('ZeroConf')`가 false, | ||
| 주변 서버 검색 버튼이 숨겨진다(수동 입력 폴백). 서버 광고(§5.1)와 JS | ||
| 게이트는 이미 준비되어 있어 네이티브만 채우면 된다. | ||
|
|
||
| ## 1. 해법 — 앱 타깃 로컬 플러그인 (외부 의존성 0) | ||
|
|
||
| - `MaestroZeroConfPlugin.swift`: `CAPPlugin + CAPBridgedPlugin`, | ||
| **jsName "ZeroConf"** — 기존 JS(`registerPlugin('ZeroConf')` 프록시)와 | ||
| 패널 게이트가 무변경으로 동작. | ||
| - `watch` (CAPPluginReturnCallback, keepAlive): NetServiceBrowser로 | ||
| `type`/`domain` 검색, resolve 완료마다 | ||
| `{action:'resolved', service:{domain,type,name,port,hostname,ipv4Addresses,ipv6Addresses,txtRecord}}` | ||
| 콜백 — 참조 플러그인의 payload 형태를 미러링(패널은 name/port/ipv4만 사용). | ||
| `added/removed` 액션도 동일 형태로 전달. | ||
| - `unwatch` (Promise): 해당 type+domain 브라우저 중지·콜백 해제. | ||
| - 미사용 메서드(register 등)는 구현하지 않는다 — 패널 사용 범위만 (YAGNI). | ||
| - `MaestroViewController.swift`: `CAPBridgeViewController` 서브클래스의 | ||
| `capacitorDidLoad()`에서 `bridge?.registerPluginInstance(...)` — SPM | ||
| 환경에서 앱 타깃 플러그인을 등록하는 표준 경로. | ||
| - `Main.storyboard`: 브리지 VC customClass를 MaestroViewController로. | ||
| - Info.plist의 `NSBonjourServices(_maestro._tcp)`/로컬 네트워크 문구는 기존 | ||
| 그대로 사용. | ||
|
|
||
| ## 2. 검증 | ||
|
|
||
| - `xcodebuild build` (시뮬레이터 SDK) 통과 — 신규 Swift 컴파일 확인. | ||
| - 시뮬레이터 런타임 증명: Mac에서 `MAESTRO_MDNS=on` 서버 실행 → 앱의 | ||
| 서버 주소 패널에 "주변 서버 (Bonjour)" 버튼이 **나타나고**(게이트 오픈) | ||
| 검색 시 호스트 서버가 목록에 잡히는지 스크린샷 기록. | ||
| - 실기기 최종 확인은 TestFlight 트랙(앱 레코드 생성 후)과 묶는다. | ||
|
|
||
| ## 3. 비범위 | ||
|
|
||
| capacitor-zeroconf 패키지 제거(의존은 JS 프록시용으로 유지), Android, | ||
| 서비스 광고(서버 쪽은 bonjour-service로 이미 동작). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // SPM 환경에서 앱 타깃 로컬 플러그인을 등록하는 표준 경로 (스펙 2026-08-05 §1). | ||
| import Capacitor | ||
| import UIKit | ||
|
|
||
| class MaestroViewController: CAPBridgeViewController { | ||
| override open func capacitorDidLoad() { | ||
| bridge?.registerPluginInstance(MaestroZeroConfPlugin()) | ||
| print("⚡️ Maestro: 로컬 ZeroConf 플러그인 등록 완료 (SPM 우회)") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| // 앱 타깃 로컬 ZeroConf 플러그인 (스펙 2026-08-05 §1). | ||
| // capacitor-zeroconf 4.0.0이 SPM을 지원하지 않아 네이티브가 미탑재되는 문제를, | ||
| // 같은 jsName("ZeroConf")·같은 payload 형태의 로컬 구현으로 채운다 — JS 무변경. | ||
| // 패널이 쓰는 범위(watch/unwatch, resolved 액션의 name/port/ipv4Addresses)만 구현 (YAGNI). | ||
| import Capacitor | ||
| import Foundation | ||
|
|
||
| @objc(MaestroZeroConfPlugin) | ||
| public class MaestroZeroConfPlugin: CAPPlugin, CAPBridgedPlugin { | ||
| public let identifier = "MaestroZeroConfPlugin" | ||
| public let jsName = "ZeroConf" | ||
| public let pluginMethods: [CAPPluginMethod] = [ | ||
| CAPPluginMethod(name: "watch", returnType: CAPPluginReturnCallback), | ||
| CAPPluginMethod(name: "unwatch", returnType: CAPPluginReturnPromise), | ||
| ] | ||
|
|
||
| private var watchers: [String: ServiceWatcher] = [:] | ||
|
|
||
| private static func watcherKey(type: String, domain: String) -> String { | ||
| return "\(type)|\(domain)" | ||
| } | ||
|
|
||
| @objc func watch(_ call: CAPPluginCall) { | ||
| guard let type = call.getString("type"), !type.isEmpty else { | ||
| call.reject("type이 필요합니다") | ||
| return | ||
| } | ||
| let domain = call.getString("domain") ?? "local." | ||
|
|
||
| call.keepAlive = true | ||
| let key = Self.watcherKey(type: type, domain: domain) | ||
|
|
||
| DispatchQueue.main.async { [weak self] in | ||
| guard let self = self else { return } | ||
| self.watchers[key]?.stop() | ||
| let watcher = ServiceWatcher(type: type, domain: domain) { [weak self] action, payload in | ||
| guard let self = self, let savedCall = self.bridge?.savedCall(withID: call.callbackId) else { return } | ||
| var result = JSObject() | ||
| result["action"] = action | ||
| result["service"] = payload | ||
| savedCall.resolve(result) | ||
| } | ||
| self.watchers[key] = watcher | ||
| watcher.start() | ||
| } | ||
| } | ||
|
|
||
| @objc func unwatch(_ call: CAPPluginCall) { | ||
| let type = call.getString("type") ?? "" | ||
| let domain = call.getString("domain") ?? "local." | ||
| let key = Self.watcherKey(type: type, domain: domain) | ||
|
|
||
| DispatchQueue.main.async { [weak self] in | ||
| self?.watchers[key]?.stop() | ||
| self?.watchers.removeValue(forKey: key) | ||
| call.resolve() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // NetServiceBrowser 기반 감시자 — 참조 플러그인(capacitor-zeroconf iOS)과 같은 | ||
| // 해석 경로를 쓴다. (NetService는 deprecated지만 시뮬레이터·실기기 모두 동작하며 | ||
| // 주소+포트 해석이 한 번에 온다.) | ||
| private final class ServiceWatcher: NSObject, NetServiceBrowserDelegate, NetServiceDelegate { | ||
| private let type: String | ||
| private let domain: String | ||
| private let onEvent: (String, JSObject) -> Void | ||
| private let browser = NetServiceBrowser() | ||
| private var pendingServices: [NetService] = [] | ||
|
|
||
| init(type: String, domain: String, onEvent: @escaping (String, JSObject) -> Void) { | ||
| self.type = type | ||
| self.domain = domain | ||
| self.onEvent = onEvent | ||
| super.init() | ||
| browser.delegate = self | ||
| } | ||
|
|
||
| func start() { | ||
| browser.searchForServices(ofType: type, inDomain: domain) | ||
| } | ||
|
|
||
| func stop() { | ||
| browser.stop() | ||
| for service in pendingServices { | ||
| service.stop() | ||
| service.delegate = nil | ||
| } | ||
| pendingServices.removeAll() | ||
| } | ||
|
|
||
| func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { | ||
| onEvent("added", Self.jsonify(service)) | ||
| service.delegate = self | ||
| pendingServices.append(service) | ||
| service.resolve(withTimeout: 5) | ||
| } | ||
|
|
||
| func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) { | ||
| onEvent("removed", Self.jsonify(service)) | ||
| } | ||
|
|
||
| func netServiceDidResolveAddress(_ sender: NetService) { | ||
| onEvent("resolved", Self.jsonify(sender)) | ||
| } | ||
|
|
||
| static func jsonify(_ service: NetService) -> JSObject { | ||
| var ipv4Addresses: [String] = [] | ||
| var ipv6Addresses: [String] = [] | ||
|
|
||
| for addressData in service.addresses ?? [] { | ||
| addressData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in | ||
| guard let base = raw.baseAddress else { return } | ||
| let family = base.assumingMemoryBound(to: sockaddr.self).pointee.sa_family | ||
| var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) | ||
| if getnameinfo( | ||
| base.assumingMemoryBound(to: sockaddr.self), | ||
| socklen_t(addressData.count), | ||
| &host, | ||
| socklen_t(host.count), | ||
| nil, | ||
| 0, | ||
| NI_NUMERICHOST | ||
| ) == 0 { | ||
| let address = String(cString: host) | ||
| if family == sa_family_t(AF_INET) { | ||
| ipv4Addresses.append(address) | ||
| } else if family == sa_family_t(AF_INET6) { | ||
| ipv6Addresses.append(address) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| var payload = JSObject() | ||
| payload["domain"] = service.domain | ||
| payload["type"] = service.type | ||
| payload["name"] = service.name | ||
| payload["port"] = service.port | ||
| payload["hostname"] = service.hostName ?? "" | ||
| payload["ipv4Addresses"] = ipv4Addresses | ||
| payload["ipv6Addresses"] = ipv6Addresses | ||
| payload["txtRecord"] = JSObject() | ||
| return payload | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the discovery timer calls
ZeroConf.unwatch(...), this stops and removes theServiceWatcher, but the correspondingwatchcall was markedkeepAlive, so Capacitor keeps the saved callback alive until it is explicitly released. Repeated “주변 서버 찾기” scans in the iOS app will therefore accumulate saved callbacks and captured JS state even though the native browser has been stopped; store the watchcallbackIdwith the watcher and release it when replacing or removing that watcher.Useful? React with 👍 / 👎.