react-native-control-center 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/app.plugin.js +14 -0
- package/cli/bin/rn-control-center.js +52 -0
- package/ios/ControlStoreRuntime.swift +81 -0
- package/ios/RNControlCenter.mm +28 -0
- package/ios/RNControlCenter.swift +195 -0
- package/lib/commonjs/cli/runGenerate.d.ts +22 -0
- package/lib/commonjs/cli/runGenerate.js +173 -0
- package/lib/commonjs/core/generate/entitlements.d.ts +17 -0
- package/lib/commonjs/core/generate/entitlements.js +31 -0
- package/lib/commonjs/core/generate/index.d.ts +30 -0
- package/lib/commonjs/core/generate/index.js +58 -0
- package/lib/commonjs/core/generate/plist.d.ts +13 -0
- package/lib/commonjs/core/generate/plist.js +37 -0
- package/lib/commonjs/core/generate/swift.d.ts +22 -0
- package/lib/commonjs/core/generate/swift.js +140 -0
- package/lib/commonjs/core/parseControls.d.ts +9 -0
- package/lib/commonjs/core/parseControls.js +206 -0
- package/lib/commonjs/core/sf-symbols-data.d.ts +3 -0
- package/lib/commonjs/core/sf-symbols-data.js +5373 -0
- package/lib/commonjs/core/templates/ButtonControl.swift.hbs +28 -0
- package/lib/commonjs/core/templates/ButtonIntent.swift.hbs +39 -0
- package/lib/commonjs/core/templates/ControlBundle.swift.hbs +17 -0
- package/lib/commonjs/core/templates/ControlStore.swift.hbs +149 -0
- package/lib/commonjs/core/templates/ToggleControl.swift.hbs +60 -0
- package/lib/commonjs/core/templates/ToggleIntent.swift.hbs +49 -0
- package/lib/commonjs/core/types.d.ts +14 -0
- package/lib/commonjs/core/types.js +17 -0
- package/lib/commonjs/core/validateSymbols.d.ts +15 -0
- package/lib/commonjs/core/validateSymbols.js +43 -0
- package/lib/commonjs/core/xcode/addSyncedFolder.d.ts +28 -0
- package/lib/commonjs/core/xcode/addSyncedFolder.js +71 -0
- package/lib/commonjs/core/xcode/addTarget.d.ts +25 -0
- package/lib/commonjs/core/xcode/addTarget.js +34 -0
- package/lib/commonjs/core/xcode/buildSettings.d.ts +14 -0
- package/lib/commonjs/core/xcode/buildSettings.js +57 -0
- package/lib/commonjs/core/xcode/embed.d.ts +16 -0
- package/lib/commonjs/core/xcode/embed.js +74 -0
- package/lib/commonjs/core/xcode/inspect.d.ts +29 -0
- package/lib/commonjs/core/xcode/inspect.js +87 -0
- package/lib/commonjs/core/xcode/linkFrameworks.d.ts +18 -0
- package/lib/commonjs/core/xcode/linkFrameworks.js +80 -0
- package/lib/commonjs/core/xcode/types.d.ts +121 -0
- package/lib/commonjs/core/xcode/types.js +7 -0
- package/lib/commonjs/core/xcode/wire.d.ts +27 -0
- package/lib/commonjs/core/xcode/wire.js +142 -0
- package/lib/commonjs/plugin/index.d.ts +43 -0
- package/lib/commonjs/plugin/index.js +177 -0
- package/lib/commonjs/src/ControlCenter.d.ts +34 -0
- package/lib/commonjs/src/ControlCenter.js +91 -0
- package/lib/commonjs/src/defineControls.d.ts +6 -0
- package/lib/commonjs/src/defineControls.js +10 -0
- package/lib/commonjs/src/hooks.d.ts +8 -0
- package/lib/commonjs/src/hooks.js +38 -0
- package/lib/commonjs/src/index.d.ts +5 -0
- package/lib/commonjs/src/index.js +9 -0
- package/lib/commonjs/src/sf-symbols.d.ts +8 -0
- package/lib/commonjs/src/sf-symbols.js +2 -0
- package/lib/commonjs/src/stateCache.d.ts +8 -0
- package/lib/commonjs/src/stateCache.js +36 -0
- package/lib/commonjs/src/types.d.ts +36 -0
- package/lib/commonjs/src/types.js +2 -0
- package/package.json +75 -0
- package/src/ControlCenter.ts +122 -0
- package/src/defineControls.ts +9 -0
- package/src/hooks.ts +42 -0
- package/src/index.ts +12 -0
- package/src/sf-symbols.ts +251 -0
- package/src/stateCache.ts +34 -0
- package/src/types.ts +36 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
import WidgetKit
|
|
4
|
+
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// 📄 RNControlCenter.swift
|
|
7
|
+
// Native Module — 위젯이 만든 이벤트를 JS로 배달하는 다리
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// 두 가지 책임:
|
|
11
|
+
// 1. Darwin notification observer 등록/해제 (위젯이 "이벤트 있음!" 신호 보냈을 때 받기)
|
|
12
|
+
// 2. ControlStore의 큐를 drain해서 JS로 이벤트 발사
|
|
13
|
+
//
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@objc(RNControlCenter)
|
|
17
|
+
class RNControlCenter: RCTEventEmitter {
|
|
18
|
+
|
|
19
|
+
// MARK: - 이벤트 이름 등록
|
|
20
|
+
//
|
|
21
|
+
// RN에게 "이 모듈은 이런 이름의 이벤트를 발사한다"고 미리 알려준다.
|
|
22
|
+
// 여기 없는 이름으로 sendEvent를 호출하면 RN이 경고를 찍고 JS는 못 받는다.
|
|
23
|
+
|
|
24
|
+
override func supportedEvents() -> [String]! {
|
|
25
|
+
return [
|
|
26
|
+
"ControlAction", // Button 탭됨 → { id, deepLink, t }
|
|
27
|
+
"ControlStateChange", // Toggle 바뀜 → { key, value, t }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// MARK: - 초기화 스레드 정책
|
|
32
|
+
//
|
|
33
|
+
// 이 모듈은 UI를 만지지 않으므로 백그라운드 스레드에서 init 가능.
|
|
34
|
+
// false 명시 안 하면 RN 0.49+에서 콘솔 경고 + 메인 스레드로 강제됨.
|
|
35
|
+
|
|
36
|
+
@objc override static func requiresMainQueueSetup() -> Bool {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - 동기 상수 (Week 6)
|
|
41
|
+
//
|
|
42
|
+
// 모듈이 JS에 등록되는 시점에 한 번 평가되어 NativeModules.RNControlCenter에
|
|
43
|
+
// 프로퍼티로 붙는다. JS의 상태 캐시가 콜드 스타트 직후에도 첫 렌더부터
|
|
44
|
+
// 값을 동기로 쓸 수 있도록 현재 상태 스냅샷을 미리 넘긴다.
|
|
45
|
+
|
|
46
|
+
@objc override func constantsToExport() -> [AnyHashable: Any]! {
|
|
47
|
+
return ["initialState": ControlStoreRuntime.shared.snapshot()]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - Lifecycle hooks
|
|
51
|
+
//
|
|
52
|
+
// RN이 자동으로 호출:
|
|
53
|
+
// startObserving() — JS에서 첫 listener가 addListener() 했을 때
|
|
54
|
+
// stopObserving() — JS에서 마지막 listener가 제거됐을 때
|
|
55
|
+
//
|
|
56
|
+
// 중간 listener 변동(2개째 추가, 1개 제거 후에도 남음 등)은 RN이 내부적으로 처리하고
|
|
57
|
+
// 여기로 호출하지 않는다. 우리는 0→1, 1→0 전환만 신경 쓰면 됨.
|
|
58
|
+
|
|
59
|
+
// RCTEventEmitter는 공개 `hasListeners`를 제공하지 않으므로 직접 추적한다.
|
|
60
|
+
// RN이 startObserving(0→1) / stopObserving(1→0)만 호출하므로 이 플래그로 충분.
|
|
61
|
+
private var hasAnyListeners = false
|
|
62
|
+
|
|
63
|
+
override func startObserving() {
|
|
64
|
+
hasAnyListeners = true
|
|
65
|
+
registerDarwinObserver()
|
|
66
|
+
|
|
67
|
+
// ⚠️ 중요 — 앱이 죽어있다 위젯 탭으로 깬 시나리오를 위해 미리 한 번 drain.
|
|
68
|
+
// 그때의 Darwin 신호는 이미 사라졌지만 큐(UserDefaults)엔 이벤트가 남아 있음.
|
|
69
|
+
drainQueueAndSendEvents()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override func stopObserving() {
|
|
73
|
+
hasAnyListeners = false
|
|
74
|
+
unregisterDarwinObserver()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// MARK: - Darwin observer 등록/해제
|
|
78
|
+
//
|
|
79
|
+
// 위젯 익스텐션이 ControlStore.postDarwinNotification()으로 발사한 신호를 받기 위해
|
|
80
|
+
// 커널 레벨 Darwin notification center에 observer를 등록한다.
|
|
81
|
+
//
|
|
82
|
+
// 까다로운 부분: CFNotificationCenterAddObserver는 C API라서 콜백을
|
|
83
|
+
// "C 함수 포인터" 형태로만 받음. Swift 클래스 메서드를 직접 못 넘김.
|
|
84
|
+
// 우회: self를 raw pointer로 변환해서 observer 인자로 넘기고,
|
|
85
|
+
// 콜백 안에서 그 포인터를 다시 self로 복원한다.
|
|
86
|
+
|
|
87
|
+
private var observerRegistered = false
|
|
88
|
+
|
|
89
|
+
private func registerDarwinObserver() {
|
|
90
|
+
guard !observerRegistered else { return }
|
|
91
|
+
|
|
92
|
+
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
|
93
|
+
|
|
94
|
+
// self의 메모리 주소를 C가 이해할 raw pointer로 변환.
|
|
95
|
+
// ARC 참조 카운트는 건드리지 않음 (Native Module은 앱 전체 수명 보장).
|
|
96
|
+
let observer = Unmanaged.passUnretained(self).toOpaque()
|
|
97
|
+
|
|
98
|
+
CFNotificationCenterAddObserver(
|
|
99
|
+
center,
|
|
100
|
+
observer,
|
|
101
|
+
{ _, observerPtr, _, _, _ in
|
|
102
|
+
// 이 클로저는 캡처 없음 → C 함수 포인터로 변환됨.
|
|
103
|
+
// observerPtr = 위에서 넘긴 self의 주소.
|
|
104
|
+
guard let observerPtr = observerPtr else { return }
|
|
105
|
+
let module = Unmanaged<RNControlCenter>
|
|
106
|
+
.fromOpaque(observerPtr)
|
|
107
|
+
.takeUnretainedValue()
|
|
108
|
+
|
|
109
|
+
// C 콜백은 임의 스레드에서 발사. RN bridge 호출은 메인 스레드가 안전.
|
|
110
|
+
DispatchQueue.main.async {
|
|
111
|
+
module.drainQueueAndSendEvents()
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
ControlStoreRuntime.shared.darwinNotificationName as CFString, // 들을 채널 이름
|
|
115
|
+
nil, // object filter (안 씀)
|
|
116
|
+
.deliverImmediately
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
observerRegistered = true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func unregisterDarwinObserver() {
|
|
123
|
+
guard observerRegistered else { return }
|
|
124
|
+
|
|
125
|
+
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
|
126
|
+
let observer = Unmanaged.passUnretained(self).toOpaque()
|
|
127
|
+
|
|
128
|
+
CFNotificationCenterRemoveObserver(
|
|
129
|
+
center,
|
|
130
|
+
observer,
|
|
131
|
+
CFNotificationName(ControlStoreRuntime.shared.darwinNotificationName as CFString),
|
|
132
|
+
nil
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
observerRegistered = false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MARK: - JS가 부르는 메서드
|
|
139
|
+
//
|
|
140
|
+
// 이 영역의 @objc func들은 RNControlCenter.mm의 RCT_EXTERN_METHOD와 1:1 매칭.
|
|
141
|
+
// 시그니처가 .mm 쪽과 어긋나면 RN이 런타임에 메서드를 못 찾고 호출 실패.
|
|
142
|
+
|
|
143
|
+
/// JS에서 useControlState 훅이 초기값 읽을 때 사용.
|
|
144
|
+
/// 예: const value = await RNControlCenter.getState('vpnEnabled')
|
|
145
|
+
@objc func getState(_ key: String,
|
|
146
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
147
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
148
|
+
let value = ControlStoreRuntime.shared.getBool(key)
|
|
149
|
+
resolve(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// JS에서 useControlState 훅이 setter로 호출할 때 사용.
|
|
153
|
+
/// 예: await RNControlCenter.setState('vpnEnabled', true)
|
|
154
|
+
/// App Group UserDefaults에 값을 쓰면 위젯이 다음 렌더링 때 그 값을 읽는다.
|
|
155
|
+
@objc func setState(_ key: String,
|
|
156
|
+
value: Bool,
|
|
157
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
158
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
159
|
+
// 1) 공유 저장소에 값 먼저 쓴다 (위젯이 다음 렌더링 때 이 값을 읽음).
|
|
160
|
+
ControlStoreRuntime.shared.setBool(key, value: value)
|
|
161
|
+
|
|
162
|
+
// 2) iOS에 "제어센터 컨트롤 다시 그려!" 요청. (Week 6)
|
|
163
|
+
// 이게 없으면 앱에서 setState로 값을 바꿔도 제어센터 토글은
|
|
164
|
+
// 옛날 그림 그대로 남아있다. WidgetKit의 ControlCenter API는 iOS 18+.
|
|
165
|
+
if #available(iOS 18.0, *) {
|
|
166
|
+
ControlCenter.shared.reloadAllControls()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
resolve(nil)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: - 큐 비우기 + JS 발사
|
|
173
|
+
//
|
|
174
|
+
// ControlStore의 두 종류 큐(action / stateChange)를 비우고
|
|
175
|
+
// 각 이벤트를 supportedEvents에 등록된 이름으로 JS에 발사.
|
|
176
|
+
//
|
|
177
|
+
// hasAnyListeners 가드: JS가 안 듣고 있으면 큐를 만지지 않는다.
|
|
178
|
+
// 안 만지면 다음 listener가 등록될 때 startObserving 안에서 drain됨.
|
|
179
|
+
|
|
180
|
+
private func drainQueueAndSendEvents() {
|
|
181
|
+
guard hasAnyListeners else { return }
|
|
182
|
+
|
|
183
|
+
// 1) Button 탭 큐
|
|
184
|
+
let actions = ControlStoreRuntime.shared.dequeueActionEvents()
|
|
185
|
+
for action in actions {
|
|
186
|
+
sendEvent(withName: "ControlAction", body: action)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2) Toggle 변경 큐
|
|
190
|
+
let stateChanges = ControlStoreRuntime.shared.dequeueStateChangeEvents()
|
|
191
|
+
for change in stateChanges {
|
|
192
|
+
sendEvent(withName: "ControlStateChange", body: change)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RunGenerateOptions {
|
|
2
|
+
/** 사용자 RN 프로젝트 루트. 기본 process.cwd() */
|
|
3
|
+
projectRoot?: string;
|
|
4
|
+
/** package.json에 설정 없을 때 콘솔에 출력할 안내 메시지를 끌지 여부 (테스트용) */
|
|
5
|
+
silent?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RunGenerateResult {
|
|
8
|
+
filesWritten: string[];
|
|
9
|
+
pbxprojPath: string;
|
|
10
|
+
widgetTargetUuid: string;
|
|
11
|
+
mainAppTargetUuid: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* RN CLI(bare) 프로젝트에서 Expo 플러그인이 하던 일을 한 번에 실행.
|
|
15
|
+
*
|
|
16
|
+
* 1) projectRoot/package.json의 "rnControlCenter" 설정 읽기
|
|
17
|
+
* 2) ios/<App>.xcodeproj/project.pbxproj 찾기
|
|
18
|
+
* 3) controls.ts 파싱 → 파일 생성 → 디스크 쓰기
|
|
19
|
+
* 4) wireXcodeProject() 호출
|
|
20
|
+
* 5) project.writeSync()로 변경 저장
|
|
21
|
+
*/
|
|
22
|
+
export declare function runGenerate(opts?: RunGenerateOptions): RunGenerateResult;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runGenerate = runGenerate;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const plist = __importStar(require("plist"));
|
|
40
|
+
const parseControls_1 = require("../core/parseControls");
|
|
41
|
+
const validateSymbols_1 = require("../core/validateSymbols");
|
|
42
|
+
const generate_1 = require("../core/generate");
|
|
43
|
+
const wire_1 = require("../core/xcode/wire");
|
|
44
|
+
const inspect_1 = require("../core/xcode/inspect");
|
|
45
|
+
const plugin_1 = require("../plugin");
|
|
46
|
+
/**
|
|
47
|
+
* RN CLI(bare) 프로젝트에서 Expo 플러그인이 하던 일을 한 번에 실행.
|
|
48
|
+
*
|
|
49
|
+
* 1) projectRoot/package.json의 "rnControlCenter" 설정 읽기
|
|
50
|
+
* 2) ios/<App>.xcodeproj/project.pbxproj 찾기
|
|
51
|
+
* 3) controls.ts 파싱 → 파일 생성 → 디스크 쓰기
|
|
52
|
+
* 4) wireXcodeProject() 호출
|
|
53
|
+
* 5) project.writeSync()로 변경 저장
|
|
54
|
+
*/
|
|
55
|
+
function runGenerate(opts = {}) {
|
|
56
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
57
|
+
const config = readPluginConfig(projectRoot);
|
|
58
|
+
const iosRoot = path.join(projectRoot, 'ios');
|
|
59
|
+
if (!fs.existsSync(iosRoot)) {
|
|
60
|
+
throw new Error(`[rn-control-center] ios/ folder not found at ${iosRoot}. Are you in an RN project root?`);
|
|
61
|
+
}
|
|
62
|
+
const pbxprojPath = findPbxprojPath(iosRoot);
|
|
63
|
+
const bundleId = config.bundleId ?? readBundleIdFromInfoPlist(iosRoot);
|
|
64
|
+
const extensionName = config.extensionName ?? 'ControlCenterExtension';
|
|
65
|
+
// 1) Parse controls
|
|
66
|
+
const controlsAbs = path.resolve(projectRoot, config.controls);
|
|
67
|
+
if (!fs.existsSync(controlsAbs)) {
|
|
68
|
+
throw new Error(`[rn-control-center] controls file not found: ${controlsAbs}`);
|
|
69
|
+
}
|
|
70
|
+
const controls = (0, parseControls_1.parseControlsFile)(controlsAbs);
|
|
71
|
+
// 1b) 심볼 오타 경고 (빌드는 막지 않음)
|
|
72
|
+
(0, validateSymbols_1.warnUnknownSymbols)(controls);
|
|
73
|
+
// 2) Generate files
|
|
74
|
+
const files = (0, generate_1.generateNativeFiles)({
|
|
75
|
+
controls,
|
|
76
|
+
bundleId,
|
|
77
|
+
urlScheme: config.urlScheme,
|
|
78
|
+
...(config.appGroupId !== undefined && { appGroupId: config.appGroupId }),
|
|
79
|
+
extensionName,
|
|
80
|
+
});
|
|
81
|
+
const filesWritten = [];
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const fullPath = path.join(iosRoot, file.path);
|
|
84
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
85
|
+
fs.writeFileSync(fullPath, file.content);
|
|
86
|
+
filesWritten.push(fullPath);
|
|
87
|
+
}
|
|
88
|
+
// 2b) 메인 앱 Info.plist에 런타임 스토어용 키 주입 (Expo 플러그인과 동일).
|
|
89
|
+
// Native Module(Pod)이 App Group ID / stateKeys를 여기서 읽는다.
|
|
90
|
+
const appGroupId = config.appGroupId ?? (0, generate_1.defaultAppGroupId)(bundleId);
|
|
91
|
+
injectMainAppInfoPlistKeys(iosRoot, pbxprojPath, {
|
|
92
|
+
RNControlCenterAppGroup: appGroupId,
|
|
93
|
+
RNControlCenterStateKeys: (0, generate_1.collectStateKeys)(controls),
|
|
94
|
+
});
|
|
95
|
+
// 3) Wire pbxproj
|
|
96
|
+
const project = (0, inspect_1.loadProject)(pbxprojPath);
|
|
97
|
+
const sharedFiles = (0, plugin_1.deriveSharedFiles)(files, extensionName);
|
|
98
|
+
const widgetBundleId = `${bundleId}.${extensionName.toLowerCase()}`;
|
|
99
|
+
const { widgetTargetUuid, mainAppTargetUuid } = (0, wire_1.wireXcodeProject)(project, {
|
|
100
|
+
mainAppBundleId: bundleId,
|
|
101
|
+
widgetTargetName: extensionName,
|
|
102
|
+
widgetBundleId,
|
|
103
|
+
sharedFiles,
|
|
104
|
+
...(config.deploymentTarget !== undefined && {
|
|
105
|
+
deploymentTarget: config.deploymentTarget,
|
|
106
|
+
}),
|
|
107
|
+
...(config.swiftVersion !== undefined && { swiftVersion: config.swiftVersion }),
|
|
108
|
+
});
|
|
109
|
+
fs.writeFileSync(pbxprojPath, project.writeSync());
|
|
110
|
+
return { filesWritten, pbxprojPath, widgetTargetUuid, mainAppTargetUuid };
|
|
111
|
+
}
|
|
112
|
+
function readPluginConfig(projectRoot) {
|
|
113
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
114
|
+
if (!fs.existsSync(pkgPath)) {
|
|
115
|
+
throw new Error(`[rn-control-center] package.json not found at ${pkgPath}. Run from RN project root.`);
|
|
116
|
+
}
|
|
117
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
118
|
+
const cfg = pkg['rnControlCenter'];
|
|
119
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
120
|
+
throw new Error('[rn-control-center] No "rnControlCenter" key in package.json. ' +
|
|
121
|
+
'Add { "controls": "./src/controls.ts", "urlScheme": "myapp" }.');
|
|
122
|
+
}
|
|
123
|
+
if (!cfg.controls || typeof cfg.controls !== 'string') {
|
|
124
|
+
throw new Error('[rn-control-center] package.json rnControlCenter.controls is required.');
|
|
125
|
+
}
|
|
126
|
+
if (!cfg.urlScheme || typeof cfg.urlScheme !== 'string') {
|
|
127
|
+
throw new Error('[rn-control-center] package.json rnControlCenter.urlScheme is required.');
|
|
128
|
+
}
|
|
129
|
+
return cfg;
|
|
130
|
+
}
|
|
131
|
+
function findPbxprojPath(iosRoot) {
|
|
132
|
+
const entries = fs.readdirSync(iosRoot);
|
|
133
|
+
const xcodeproj = entries.find((e) => e.endsWith('.xcodeproj'));
|
|
134
|
+
if (!xcodeproj) {
|
|
135
|
+
throw new Error(`[rn-control-center] No *.xcodeproj found inside ${iosRoot}.`);
|
|
136
|
+
}
|
|
137
|
+
return path.join(iosRoot, xcodeproj, 'project.pbxproj');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 메인 앱 타겟의 Info.plist에 키를 병합 저장.
|
|
141
|
+
* bare RN 레이아웃은 ios/<AppName>/Info.plist (AppName = .xcodeproj 이름).
|
|
142
|
+
* Info.plist를 못 찾으면 조용히 건너뛴다 (라이브러리가 빌드를 막지 않도록).
|
|
143
|
+
*/
|
|
144
|
+
function injectMainAppInfoPlistKeys(iosRoot, pbxprojPath, keys) {
|
|
145
|
+
// pbxprojPath = ios/<App>.xcodeproj/project.pbxproj → <App> 추출
|
|
146
|
+
const appName = path.basename(path.dirname(pbxprojPath)).replace(/\.xcodeproj$/, '');
|
|
147
|
+
const plistPath = path.join(iosRoot, appName, 'Info.plist');
|
|
148
|
+
if (!fs.existsSync(plistPath))
|
|
149
|
+
return;
|
|
150
|
+
const parsed = plist.parse(fs.readFileSync(plistPath, 'utf-8'));
|
|
151
|
+
const merged = { ...parsed, ...keys };
|
|
152
|
+
fs.writeFileSync(plistPath, plist.build(merged));
|
|
153
|
+
}
|
|
154
|
+
function readBundleIdFromInfoPlist(iosRoot) {
|
|
155
|
+
// RN CLI 프로젝트엔 ios/<AppName>/Info.plist에 CFBundleIdentifier가 적혀있다.
|
|
156
|
+
// 보통 $(PRODUCT_BUNDLE_IDENTIFIER) 변수 형태인 경우가 많아 fallback 필요.
|
|
157
|
+
const subdirs = fs
|
|
158
|
+
.readdirSync(iosRoot, { withFileTypes: true })
|
|
159
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
160
|
+
.map((d) => d.name);
|
|
161
|
+
for (const dir of subdirs) {
|
|
162
|
+
const plistPath = path.join(iosRoot, dir, 'Info.plist');
|
|
163
|
+
if (!fs.existsSync(plistPath))
|
|
164
|
+
continue;
|
|
165
|
+
const content = fs.readFileSync(plistPath, 'utf-8');
|
|
166
|
+
const match = content.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/);
|
|
167
|
+
if (match && match[1] && !match[1].includes('$(')) {
|
|
168
|
+
return match[1];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error('[rn-control-center] Could not infer bundleId from any Info.plist. ' +
|
|
172
|
+
'Set "bundleId" explicitly in package.json rnControlCenter section.');
|
|
173
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface GenerateEntitlementsOptions {
|
|
2
|
+
/** 메인 앱과 위젯이 공유할 App Group ID. */
|
|
3
|
+
appGroupId: string;
|
|
4
|
+
/** 기존 엔타이틀먼트와 병합할 키 (옵션). */
|
|
5
|
+
merge?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 메인 앱 + 위젯 익스텐션이 동일한 entitlements를 가져야
|
|
9
|
+
* App Group UserDefaults가 둘 사이에 공유됨.
|
|
10
|
+
*
|
|
11
|
+
* 같은 파일을 두 타겟이 참조하든, 똑같은 내용으로 두 파일을 두든 무방.
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateAppGroupEntitlements(opts: GenerateEntitlementsOptions): string;
|
|
14
|
+
/**
|
|
15
|
+
* 기존 entitlement 파일이 있으면 그 위에 App Group을 안전하게 병합.
|
|
16
|
+
*/
|
|
17
|
+
export declare function mergeAppGroupIntoEntitlements(existingPlistXml: string, appGroupId: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateAppGroupEntitlements = generateAppGroupEntitlements;
|
|
7
|
+
exports.mergeAppGroupIntoEntitlements = mergeAppGroupIntoEntitlements;
|
|
8
|
+
const plist_1 = __importDefault(require("plist"));
|
|
9
|
+
/**
|
|
10
|
+
* 메인 앱 + 위젯 익스텐션이 동일한 entitlements를 가져야
|
|
11
|
+
* App Group UserDefaults가 둘 사이에 공유됨.
|
|
12
|
+
*
|
|
13
|
+
* 같은 파일을 두 타겟이 참조하든, 똑같은 내용으로 두 파일을 두든 무방.
|
|
14
|
+
*/
|
|
15
|
+
function generateAppGroupEntitlements(opts) {
|
|
16
|
+
const root = {
|
|
17
|
+
'com.apple.security.application-groups': [opts.appGroupId],
|
|
18
|
+
...(opts.merge ?? {}),
|
|
19
|
+
};
|
|
20
|
+
return plist_1.default.build(root, { indent: '\t' });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 기존 entitlement 파일이 있으면 그 위에 App Group을 안전하게 병합.
|
|
24
|
+
*/
|
|
25
|
+
function mergeAppGroupIntoEntitlements(existingPlistXml, appGroupId) {
|
|
26
|
+
const parsed = plist_1.default.parse(existingPlistXml);
|
|
27
|
+
const existing = parsed['com.apple.security.application-groups'] ?? [];
|
|
28
|
+
const merged = Array.from(new Set([...existing, appGroupId]));
|
|
29
|
+
parsed['com.apple.security.application-groups'] = merged;
|
|
30
|
+
return plist_1.default.build(parsed, { indent: '\t' });
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defaultAppGroupId, collectStateKeys, type GeneratedFile } from './swift';
|
|
2
|
+
import type { ParsedControl } from '../types';
|
|
3
|
+
export interface GenerateNativeOptions {
|
|
4
|
+
controls: ParsedControl[];
|
|
5
|
+
/** 메인 앱 bundle ID (예: "com.acme.app"). */
|
|
6
|
+
bundleId: string;
|
|
7
|
+
/** 딥링크 URL scheme (예: "acme"). */
|
|
8
|
+
urlScheme: string;
|
|
9
|
+
/** App Group 식별자. 미지정 시 `group.{bundleId}.controls`. */
|
|
10
|
+
appGroupId?: string;
|
|
11
|
+
/** Widget Extension 디렉터리 이름. 기본 "ControlCenterExtension". */
|
|
12
|
+
extensionName?: string;
|
|
13
|
+
/** Bundle struct 이름. 기본 "ControlCenterBundle". */
|
|
14
|
+
bundleStructName?: string;
|
|
15
|
+
}
|
|
16
|
+
export type FileTarget = 'extension' | 'app' | 'shared';
|
|
17
|
+
export interface NativeFile {
|
|
18
|
+
/** 출력 상대 경로 (예: "ControlCenterExtension/Controls/QuickNoteControl.swift"). */
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
/** 어느 타겟에 멤버십을 부여해야 하는지 — Week 3 pbxproj 작업에서 사용. */
|
|
22
|
+
target: FileTarget;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 라이브러리의 단일 코드 생성 진입점.
|
|
26
|
+
* Swift 파일 + Info.plist + entitlement 두 파일을 한꺼번에 만들어 반환.
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateNativeFiles(opts: GenerateNativeOptions): NativeFile[];
|
|
29
|
+
export { defaultAppGroupId, collectStateKeys };
|
|
30
|
+
export type { GeneratedFile };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectStateKeys = exports.defaultAppGroupId = void 0;
|
|
4
|
+
exports.generateNativeFiles = generateNativeFiles;
|
|
5
|
+
const swift_1 = require("./swift");
|
|
6
|
+
Object.defineProperty(exports, "defaultAppGroupId", { enumerable: true, get: function () { return swift_1.defaultAppGroupId; } });
|
|
7
|
+
Object.defineProperty(exports, "collectStateKeys", { enumerable: true, get: function () { return swift_1.collectStateKeys; } });
|
|
8
|
+
const plist_1 = require("./plist");
|
|
9
|
+
const entitlements_1 = require("./entitlements");
|
|
10
|
+
/**
|
|
11
|
+
* 라이브러리의 단일 코드 생성 진입점.
|
|
12
|
+
* Swift 파일 + Info.plist + entitlement 두 파일을 한꺼번에 만들어 반환.
|
|
13
|
+
*/
|
|
14
|
+
function generateNativeFiles(opts) {
|
|
15
|
+
const extName = opts.extensionName ?? 'ControlCenterExtension';
|
|
16
|
+
const appGroupId = opts.appGroupId ?? (0, swift_1.defaultAppGroupId)(opts.bundleId);
|
|
17
|
+
const files = [];
|
|
18
|
+
// 1) Swift sources
|
|
19
|
+
const swiftFiles = (0, swift_1.generateSwiftFiles)({
|
|
20
|
+
controls: opts.controls,
|
|
21
|
+
bundleId: opts.bundleId,
|
|
22
|
+
urlScheme: opts.urlScheme,
|
|
23
|
+
appGroupId,
|
|
24
|
+
bundleStructName: opts.bundleStructName,
|
|
25
|
+
});
|
|
26
|
+
for (const f of swiftFiles) {
|
|
27
|
+
// ControlStore + Intents는 두 타겟이 함께 가져야 함 (양방향 통신).
|
|
28
|
+
// ControlBundle, Controls는 위젯 익스텐션 전용.
|
|
29
|
+
const target = isSharedSwiftFile(f.path) ? 'shared' : 'extension';
|
|
30
|
+
files.push({
|
|
31
|
+
path: `${extName}/${f.path}`,
|
|
32
|
+
content: f.content,
|
|
33
|
+
target,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// 2) Widget Extension Info.plist
|
|
37
|
+
files.push({
|
|
38
|
+
path: `${extName}/Info.plist`,
|
|
39
|
+
content: (0, plist_1.generateExtensionInfoPlist)({ extensionBundleName: extName }),
|
|
40
|
+
target: 'extension',
|
|
41
|
+
});
|
|
42
|
+
// 3) Entitlements — 두 타겟 동일
|
|
43
|
+
const entitlements = (0, entitlements_1.generateAppGroupEntitlements)({ appGroupId });
|
|
44
|
+
files.push({
|
|
45
|
+
path: `${extName}/${extName}.entitlements`,
|
|
46
|
+
content: entitlements,
|
|
47
|
+
target: 'extension',
|
|
48
|
+
});
|
|
49
|
+
files.push({
|
|
50
|
+
path: `${extName}/MainApp.entitlements`,
|
|
51
|
+
content: entitlements,
|
|
52
|
+
target: 'app',
|
|
53
|
+
});
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
function isSharedSwiftFile(swiftPath) {
|
|
57
|
+
return swiftPath === 'ControlStore.swift' || swiftPath.startsWith('Intents/');
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GeneratePlistOptions {
|
|
2
|
+
/** Widget Extension의 bundle ID. 디스플레이 이름 보정에 쓰임. */
|
|
3
|
+
extensionBundleName?: string;
|
|
4
|
+
/** SDK 호환을 위한 추가 키. 사용자 커스텀 가능. */
|
|
5
|
+
extra?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Widget Extension용 Info.plist 문자열 생성.
|
|
9
|
+
*
|
|
10
|
+
* NSExtensionPointIdentifier = com.apple.widgetkit-extension 이 핵심.
|
|
11
|
+
* iOS가 이 값을 보고 ControlWidget으로 인식.
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateExtensionInfoPlist(opts?: GeneratePlistOptions): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateExtensionInfoPlist = generateExtensionInfoPlist;
|
|
7
|
+
const plist_1 = __importDefault(require("plist"));
|
|
8
|
+
/**
|
|
9
|
+
* Widget Extension용 Info.plist 문자열 생성.
|
|
10
|
+
*
|
|
11
|
+
* NSExtensionPointIdentifier = com.apple.widgetkit-extension 이 핵심.
|
|
12
|
+
* iOS가 이 값을 보고 ControlWidget으로 인식.
|
|
13
|
+
*/
|
|
14
|
+
function generateExtensionInfoPlist(opts = {}) {
|
|
15
|
+
// GENERATE_INFOPLIST_FILE=NO 일 때 Xcode가 표준 키를 자동 채워주지 않으므로
|
|
16
|
+
// AppIntentsSSU 같은 후속 빌드 도구가 요구하는 최소 키들을 우리가 직접 넣어준다.
|
|
17
|
+
const root = {
|
|
18
|
+
CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)',
|
|
19
|
+
CFBundleExecutable: '$(EXECUTABLE_NAME)',
|
|
20
|
+
CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
|
|
21
|
+
CFBundleInfoDictionaryVersion: '6.0',
|
|
22
|
+
CFBundleName: '$(PRODUCT_NAME)',
|
|
23
|
+
CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
|
|
24
|
+
CFBundleShortVersionString: '1.0',
|
|
25
|
+
CFBundleVersion: '1',
|
|
26
|
+
NSExtension: {
|
|
27
|
+
NSExtensionPointIdentifier: 'com.apple.widgetkit-extension',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (opts.extensionBundleName) {
|
|
31
|
+
root.CFBundleDisplayName = opts.extensionBundleName;
|
|
32
|
+
}
|
|
33
|
+
if (opts.extra) {
|
|
34
|
+
Object.assign(root, opts.extra);
|
|
35
|
+
}
|
|
36
|
+
return plist_1.default.build(root, { indent: ' ' });
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ParsedControl } from '../types';
|
|
2
|
+
export interface GenerateOptions {
|
|
3
|
+
controls: ParsedControl[];
|
|
4
|
+
bundleId: string;
|
|
5
|
+
urlScheme: string;
|
|
6
|
+
appGroupId?: string;
|
|
7
|
+
bundleStructName?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function defaultAppGroupId(bundleId: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* 토글 컨트롤들의 stateKey 목록.
|
|
12
|
+
* - 생성된 ControlStore.swift의 정적 화이트리스트로 박힘
|
|
13
|
+
* - Native Module의 snapshot() 정제를 위해 메인 앱 Info.plist
|
|
14
|
+
* ("RNControlCenterStateKeys")로도 주입됨
|
|
15
|
+
*/
|
|
16
|
+
export declare function collectStateKeys(controls: ParsedControl[]): string[];
|
|
17
|
+
export interface GeneratedFile {
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function pascalCase(str: string): string;
|
|
22
|
+
export declare function generateSwiftFiles(opts: GenerateOptions): GeneratedFile[];
|