Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/superpowers/specs/2026-08-05-ios-local-zeroconf-design.md
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로 이미 동작).
8 changes: 8 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -22,6 +24,8 @@
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
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 = "<group>"; };
A11ZC0021A2B3C4D5E6F7002 /* MaestroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroViewController.swift; sourceTree = "<group>"; };
A11ZC0041A2B3C4D5E6F7004 /* MaestroZeroConfPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroZeroConfPlugin.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -156,6 +162,8 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
A11ZC0011A2B3C4D5E6F7001 /* MaestroViewController.swift in Sources */,
A11ZC0031A2B3C4D5E6F7003 /* MaestroZeroConfPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 1 addition & 1 deletion ios/App/App/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="MaestroViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
Expand Down
10 changes: 10 additions & 0 deletions ios/App/App/MaestroViewController.swift
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 우회)")
}
}
146 changes: 146 additions & 0 deletions ios/App/App/MaestroZeroConfPlugin.swift
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the kept-alive watch callback on unwatch

When the discovery timer calls ZeroConf.unwatch(...), this stops and removes the ServiceWatcher, but the corresponding watch call was marked keepAlive, 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 watch callbackId with the watcher and release it when replacing or removing that watcher.

Useful? React with 👍 / 👎.

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