From 4fa6c1a578b7ae95c9ba571981cfff8522735401 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 16:30:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(ios):=20=EB=A1=9C=EC=BB=AC=20ZeroConf?= =?UTF-8?q?=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?(SPM=20=EB=AF=B8=EC=A7=80=EC=9B=90=20=EC=9A=B0=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-05-ios-local-zeroconf-design.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md diff --git a/docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md b/docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md new file mode 100644 index 0000000..30c2012 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md @@ -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로 이미 동작). From 89dddd8105230442b017b276faf866f395a5679e Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 16:40:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(ios):=20=EC=95=B1=20=ED=83=80=EA=B9=83?= =?UTF-8?q?=20=EB=A1=9C=EC=BB=AC=20ZeroConf=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=E2=80=94=20=EC=8B=A4=EA=B8=B0=EA=B8=B0=20Bonjour?= =?UTF-8?q?=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EA=B0=9C=EB=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capacitor-zeroconf 4.0.0의 SPM 미지원(Package.swift 부재)으로 네이티브가 미탑재되던 문제를, 같은 jsName("ZeroConf")·같은 payload 형태의 로컬 Swift 플러그인(NetServiceBrowser 기반 watch/unwatch)으로 채운다. MaestroViewController(capacitorDidLoad)에서 registerPluginInstance — JS(registerPlugin 프록시)와 패널 런타임 게이트는 무변경으로 동작한다. 시뮬레이터 증거: 빌드 성공 + 앱 렌더 + 부팅 콘솔에 플러그인 등록 로그. 검증 중 발견: 네이티브 검증은 npm run ios:build(CAPACITOR_BUILD=1)를 써야 함 — 일반 build는 GH Pages base(/maestro-coding/)라 흰 화면. Co-Authored-By: Claude Fable 5 --- ios/App/App.xcodeproj/project.pbxproj | 8 ++ ios/App/App/Base.lproj/Main.storyboard | 2 +- ios/App/App/MaestroViewController.swift | 10 ++ ios/App/App/MaestroZeroConfPlugin.swift | 146 ++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 ios/App/App/MaestroViewController.swift create mode 100644 ios/App/App/MaestroZeroConfPlugin.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 487ee58..47664ed 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + A11ZC0011A2B3C4D5E6F7001 /* MaestroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11ZC0021A2B3C4D5E6F7002 /* MaestroViewController.swift */; }; + A11ZC0031A2B3C4D5E6F7003 /* MaestroZeroConfPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11ZC0041A2B3C4D5E6F7004 /* MaestroZeroConfPlugin.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; @@ -22,6 +24,8 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A11ZC0021A2B3C4D5E6F7002 /* MaestroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroViewController.swift; sourceTree = ""; }; + A11ZC0041A2B3C4D5E6F7004 /* MaestroZeroConfPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroZeroConfPlugin.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -64,6 +68,8 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + A11ZC0021A2B3C4D5E6F7002 /* MaestroViewController.swift */, + A11ZC0041A2B3C4D5E6F7004 /* MaestroZeroConfPlugin.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -156,6 +162,8 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + A11ZC0011A2B3C4D5E6F7001 /* MaestroViewController.swift in Sources */, + A11ZC0031A2B3C4D5E6F7003 /* MaestroZeroConfPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard index b44df7b..b143c09 100644 --- a/ios/App/App/Base.lproj/Main.storyboard +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/ios/App/App/MaestroViewController.swift b/ios/App/App/MaestroViewController.swift new file mode 100644 index 0000000..6372dfa --- /dev/null +++ b/ios/App/App/MaestroViewController.swift @@ -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 우회)") + } +} diff --git a/ios/App/App/MaestroZeroConfPlugin.swift b/ios/App/App/MaestroZeroConfPlugin.swift new file mode 100644 index 0000000..c0d2e6f --- /dev/null +++ b/ios/App/App/MaestroZeroConfPlugin.swift @@ -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 + } +}