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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/app.plugin.js +14 -0
  4. package/cli/bin/rn-control-center.js +52 -0
  5. package/ios/ControlStoreRuntime.swift +81 -0
  6. package/ios/RNControlCenter.mm +28 -0
  7. package/ios/RNControlCenter.swift +195 -0
  8. package/lib/commonjs/cli/runGenerate.d.ts +22 -0
  9. package/lib/commonjs/cli/runGenerate.js +173 -0
  10. package/lib/commonjs/core/generate/entitlements.d.ts +17 -0
  11. package/lib/commonjs/core/generate/entitlements.js +31 -0
  12. package/lib/commonjs/core/generate/index.d.ts +30 -0
  13. package/lib/commonjs/core/generate/index.js +58 -0
  14. package/lib/commonjs/core/generate/plist.d.ts +13 -0
  15. package/lib/commonjs/core/generate/plist.js +37 -0
  16. package/lib/commonjs/core/generate/swift.d.ts +22 -0
  17. package/lib/commonjs/core/generate/swift.js +140 -0
  18. package/lib/commonjs/core/parseControls.d.ts +9 -0
  19. package/lib/commonjs/core/parseControls.js +206 -0
  20. package/lib/commonjs/core/sf-symbols-data.d.ts +3 -0
  21. package/lib/commonjs/core/sf-symbols-data.js +5373 -0
  22. package/lib/commonjs/core/templates/ButtonControl.swift.hbs +28 -0
  23. package/lib/commonjs/core/templates/ButtonIntent.swift.hbs +39 -0
  24. package/lib/commonjs/core/templates/ControlBundle.swift.hbs +17 -0
  25. package/lib/commonjs/core/templates/ControlStore.swift.hbs +149 -0
  26. package/lib/commonjs/core/templates/ToggleControl.swift.hbs +60 -0
  27. package/lib/commonjs/core/templates/ToggleIntent.swift.hbs +49 -0
  28. package/lib/commonjs/core/types.d.ts +14 -0
  29. package/lib/commonjs/core/types.js +17 -0
  30. package/lib/commonjs/core/validateSymbols.d.ts +15 -0
  31. package/lib/commonjs/core/validateSymbols.js +43 -0
  32. package/lib/commonjs/core/xcode/addSyncedFolder.d.ts +28 -0
  33. package/lib/commonjs/core/xcode/addSyncedFolder.js +71 -0
  34. package/lib/commonjs/core/xcode/addTarget.d.ts +25 -0
  35. package/lib/commonjs/core/xcode/addTarget.js +34 -0
  36. package/lib/commonjs/core/xcode/buildSettings.d.ts +14 -0
  37. package/lib/commonjs/core/xcode/buildSettings.js +57 -0
  38. package/lib/commonjs/core/xcode/embed.d.ts +16 -0
  39. package/lib/commonjs/core/xcode/embed.js +74 -0
  40. package/lib/commonjs/core/xcode/inspect.d.ts +29 -0
  41. package/lib/commonjs/core/xcode/inspect.js +87 -0
  42. package/lib/commonjs/core/xcode/linkFrameworks.d.ts +18 -0
  43. package/lib/commonjs/core/xcode/linkFrameworks.js +80 -0
  44. package/lib/commonjs/core/xcode/types.d.ts +121 -0
  45. package/lib/commonjs/core/xcode/types.js +7 -0
  46. package/lib/commonjs/core/xcode/wire.d.ts +27 -0
  47. package/lib/commonjs/core/xcode/wire.js +142 -0
  48. package/lib/commonjs/plugin/index.d.ts +43 -0
  49. package/lib/commonjs/plugin/index.js +177 -0
  50. package/lib/commonjs/src/ControlCenter.d.ts +34 -0
  51. package/lib/commonjs/src/ControlCenter.js +91 -0
  52. package/lib/commonjs/src/defineControls.d.ts +6 -0
  53. package/lib/commonjs/src/defineControls.js +10 -0
  54. package/lib/commonjs/src/hooks.d.ts +8 -0
  55. package/lib/commonjs/src/hooks.js +38 -0
  56. package/lib/commonjs/src/index.d.ts +5 -0
  57. package/lib/commonjs/src/index.js +9 -0
  58. package/lib/commonjs/src/sf-symbols.d.ts +8 -0
  59. package/lib/commonjs/src/sf-symbols.js +2 -0
  60. package/lib/commonjs/src/stateCache.d.ts +8 -0
  61. package/lib/commonjs/src/stateCache.js +36 -0
  62. package/lib/commonjs/src/types.d.ts +36 -0
  63. package/lib/commonjs/src/types.js +2 -0
  64. package/package.json +75 -0
  65. package/src/ControlCenter.ts +122 -0
  66. package/src/defineControls.ts +9 -0
  67. package/src/hooks.ts +42 -0
  68. package/src/index.ts +12 -0
  69. package/src/sf-symbols.ts +251 -0
  70. package/src/stateCache.ts +34 -0
  71. package/src/types.ts +36 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 darby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # react-native-control-center
2
+
3
+ iOS 18+ Control Center custom controls for React Native — declare in TypeScript, zero Swift required.
4
+
5
+ ![status](https://img.shields.io/badge/status-v0.1.0-brightgreen) ![iOS](https://img.shields.io/badge/iOS-18%2B-blue) ![license](https://img.shields.io/badge/license-MIT-green)
6
+
7
+ > **v0.1.0.** Build-time pipeline (codegen + pbxproj wiring + Expo plugin + CLI) and runtime native module (Darwin observer → queue drain → JS events) are complete and compile end-to-end against the real iOS 18 SDK. The runtime hook gives synchronous initial state via a cache and re-renders Control Center on programmatic state change; a 5,000+ SF Symbol set backs build-time spell-check. See [Installation](#installation) and the [Roadmap](#roadmap).
8
+
9
+ ---
10
+
11
+ ## What it does
12
+
13
+ Declare controls in TypeScript:
14
+
15
+ ```ts
16
+ // src/controls.ts
17
+ import { defineControls } from 'react-native-control-center';
18
+
19
+ export default defineControls({
20
+ quickNote: {
21
+ type: 'button',
22
+ title: 'Quick Note',
23
+ icon: 'square.and.pencil',
24
+ },
25
+ vpnToggle: {
26
+ type: 'toggle',
27
+ title: 'VPN',
28
+ icons: { on: 'lock.fill', off: 'lock.open' },
29
+ stateKey: 'vpnEnabled',
30
+ },
31
+ });
32
+ ```
33
+
34
+ Add one line to `app.json`:
35
+
36
+ ```json
37
+ {
38
+ "expo": {
39
+ "plugins": [
40
+ ["react-native-control-center", {
41
+ "controls": "./src/controls.ts",
42
+ "urlScheme": "myapp"
43
+ }]
44
+ ]
45
+ }
46
+ }
47
+ ```
48
+
49
+ Run `npx expo prebuild` and the library:
50
+
51
+ - generates a Widget Extension target
52
+ - writes the `ControlWidget` + `AppIntent` Swift files
53
+ - links `AppIntents.framework` and `WidgetKit.framework`
54
+ - wires up App Group entitlement for two-way state sync
55
+ - registers a URL scheme for deep linking
56
+
57
+ React to taps in your app:
58
+
59
+ ```ts
60
+ import { ControlCenter, useControlState } from 'react-native-control-center';
61
+
62
+ // Button taps
63
+ ControlCenter.onAction(({ id }) => {
64
+ if (id === 'quickNote') navigation.navigate('NewNote');
65
+ });
66
+
67
+ // Bidirectional toggle state
68
+ const [isVPN, setVPN] = useControlState<boolean>('vpnEnabled');
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Requirements
74
+
75
+ | | Minimum |
76
+ | --- | --- |
77
+ | iOS (controls visible) | **18.0+** — on iOS 17 and below the library loads and no-ops; controls just don't appear |
78
+ | Xcode | **16+** (ships the iOS 18 SDK with `ControlWidget` / WidgetKit `ControlCenter`) |
79
+ | React Native | **0.74+** |
80
+ | Expo (optional) | **SDK 54+** if you use the config plugin |
81
+
82
+ Android is a safe no-op today; a Quick Settings Tiles backend is planned (see [Roadmap](#roadmap)).
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ npm install react-native-control-center
88
+ ```
89
+
90
+ **Expo** — add the config plugin to `app.json`, then prebuild:
91
+
92
+ ```json
93
+ { "expo": { "plugins": [["react-native-control-center", {
94
+ "controls": "./src/controls.ts",
95
+ "urlScheme": "myapp"
96
+ }]] } }
97
+ ```
98
+
99
+ ```bash
100
+ npx expo prebuild --clean && npx expo run:ios
101
+ ```
102
+
103
+ **Bare React Native** — add a `rnControlCenter` block to `package.json`, then run the CLI:
104
+
105
+ ```json
106
+ { "rnControlCenter": { "controls": "./src/controls.ts", "urlScheme": "myapp" } }
107
+ ```
108
+
109
+ ```bash
110
+ npx rn-control-center generate && cd ios && pod install && cd .. && npx react-native run-ios
111
+ ```
112
+
113
+ See runnable references in [`examples/expo`](examples/expo) and [`examples/bare-rn`](examples/bare-rn).
114
+
115
+ ## Why this exists
116
+
117
+ Apple's iOS 18 Control Widgets are powerful, but wiring them up from React Native currently means:
118
+
119
+ 1. Opening Xcode
120
+ 2. Adding a Widget Extension target
121
+ 3. Writing SwiftUI `ControlWidget` by hand
122
+ 4. Defining an `AppIntent`
123
+ 5. Configuring an App Group
124
+ 6. Setting up a URL scheme
125
+ 7. Bridging taps back to JS
126
+
127
+ `@bacons/apple-targets` solves step 1 — but leaves you with Swift, plists, and entitlements to manage yourself.
128
+
129
+ This library takes a declarative TypeScript config and generates the full native extension, including the pieces needed for two-way state sync with your React Native app.
130
+
131
+ ---
132
+
133
+ ## How it works
134
+
135
+ There are two distinct flows worth understanding: what happens **at build time**
136
+ when you run `expo prebuild` (or `rn-control-center generate`), and what happens
137
+ **at runtime** when a user taps a control in Control Center.
138
+
139
+ ### Build-time pipeline
140
+
141
+ ```
142
+ [ npx expo prebuild ] [ npx rn-control-center generate ]
143
+ │ │
144
+ ▼ ▼
145
+ Expo reads app.json plugins cli/bin reads package.json
146
+ │ │
147
+ ▼ ▼
148
+ plugin/index.ts cli/runGenerate.ts
149
+ withControlCenter(config, props) runGenerate({ projectRoot })
150
+ │ │
151
+ ├── validateProps() │
152
+ │ │
153
+ ├── withDangerousMod(...) ────────┐ │
154
+ │ parseControlsFile() │ │
155
+ │ generateNativeFiles() │ │
156
+ │ fs.writeFileSync(...) │ │
157
+ │ │ │
158
+ └── withXcodeProject(...) ────────┘ │
159
+ wireXcodeProject(project, opts) │
160
+
161
+
162
+ ┌──── parseControlsFile() ◄──── reads ./src/controls.ts and turns
163
+ │ the defineControls({...}) literal
164
+ │ into ParsedControl[] (Babel AST)
165
+
166
+ ├──── generateNativeFiles() ──► emits 8 NativeFile records:
167
+ │ • ControlBundle.swift
168
+ │ • ControlStore.swift
169
+ │ • Controls/<Name>Control.swift × N
170
+ │ • Intents/<Name>Intent.swift × N
171
+ │ • Info.plist
172
+ │ • <ext>.entitlements (widget)
173
+ │ • MainApp.entitlements (main app)
174
+
175
+ ├──── fs.writeFileSync(...) ──► writes the eight files into
176
+ │ ios/ControlCenterExtension/
177
+
178
+ └──── wireXcodeProject(...) ──► mutates project.pbxproj:
179
+ ├── addWidgetExtensionTarget() app-extension target
180
+ │ + auto PBXCopyFilesBuildPhase
181
+ │ embedding the .appex
182
+ ├── linkFrameworks(widget, one PBXFileReference,
183
+ │ ['WidgetKit','SwiftUI', one PBXBuildFile per
184
+ │ 'AppIntents']) target's Frameworks phase
185
+ ├── linkFrameworks(mainApp,
186
+ │ ['AppIntents'])
187
+ ├── addSyncedSourceFolder() PBXFileSystemSynchronizedRootGroup
188
+ │ + 2 ExceptionSets:
189
+ │ • shared files → main app
190
+ │ • plist/entitlements → exclude widget
191
+ ├── setTargetBuildSettings(widget, IPHONEOS_DEPLOYMENT_TARGET=18.0,
192
+ │ {...}) INFOPLIST_FILE,
193
+ │ CODE_SIGN_ENTITLEMENTS,
194
+ │ GENERATE_INFOPLIST_FILE=NO, ...
195
+ ├── setTargetBuildSettings(mainApp, CODE_SIGN_ENTITLEMENTS,
196
+ │ {...}) IPHONEOS_DEPLOYMENT_TARGET≥16.0
197
+ └── verifyEmbedded() sanity check
198
+
199
+
200
+ project.writeSync()
201
+
202
+
203
+ CocoaPods install
204
+
205
+
206
+ ios/ ready to xcodebuild
207
+ ```
208
+
209
+ ### Runtime — Button tap (e.g. "Quick Note")
210
+
211
+ ```
212
+ ① user taps "Quick Note" in Control Center
213
+
214
+
215
+ ② iOS wakes the widget extension process
216
+
217
+
218
+ ③ QuickNoteIntent.perform() runs (in widget process)
219
+ │ ControlStore.shared.enqueueAction(id, deepLink)
220
+ │ └── push event to App Group UserDefaults queue
221
+ │ └── post a Darwin notification
222
+ │ return .result()
223
+
224
+
225
+ ④ iOS sees `static let openAppWhenRun: Bool = true`
226
+ └── brings the main app to the foreground
227
+
228
+
229
+ ⑤ Main app starts/resumes
230
+ └── (Week 5) Native Module observes the Darwin notification,
231
+ drains the App Group queue, emits a JS event
232
+
233
+
234
+ ⑥ ControlCenter.onAction(({ id }) => ...) fires in JS
235
+ ```
236
+
237
+ ### Runtime — Toggle tap (e.g. "VPN")
238
+
239
+ Two phases interleave: **rendering** (whenever Control Center asks the widget
240
+ to draw itself) and **action** (when the user actually taps the toggle).
241
+
242
+ ```
243
+ [ rendering ]
244
+ ① Control Center asks the widget for its current state
245
+
246
+
247
+ ② Provider.currentValue() runs
248
+ └── ControlStore.shared.getBool('vpnEnabled')
249
+ └── reads from App Group UserDefaults
250
+
251
+
252
+ ③ iOS draws the toggle with the returned value
253
+ └── on-icon vs off-icon, on-tint vs off-tint
254
+
255
+ [ action ]
256
+ ① user taps the toggle (currently OFF)
257
+
258
+
259
+ ② iOS computes the new value (true) and injects into VpnToggleIntent.value
260
+
261
+
262
+ ③ VpnToggleIntent.perform() runs
263
+ │ ControlStore.shared.setBool('vpnEnabled', true)
264
+ │ └── write to App Group UserDefaults FIRST
265
+ │ ControlStore.shared.enqueueStateChange('vpnEnabled', true)
266
+ │ └── push event to queue + post Darwin notification
267
+ │ return .result()
268
+
269
+
270
+ ④ iOS re-runs the rendering flow above; toggle visually flips to ON
271
+
272
+
273
+ ⑤ (Week 5) Native Module drains the queue, emits a JS event
274
+ └── useControlState('vpnEnabled') hook updates → UI rerenders
275
+ ```
276
+
277
+ ---
278
+
279
+ ## API
280
+
281
+ ### `defineControls(map)`
282
+
283
+ Build-time only. Declares your controls as a literal object (literal values
284
+ only — no variables or function calls, so codegen never runs your code).
285
+
286
+ ```ts
287
+ defineControls({
288
+ quickNote: { type: 'button', title: 'Quick Note', icon: 'square.and.pencil', deepLink?, tint?, description? },
289
+ vpnToggle: { type: 'toggle', title: 'VPN', icons: { on, off }, stateKey: 'vpnEnabled', tint?, description? },
290
+ });
291
+ ```
292
+
293
+ ### `ControlCenter`
294
+
295
+ Runtime singleton. Safe no-op off iOS 18.
296
+
297
+ | Member | Description |
298
+ | --- | --- |
299
+ | `isAvailable(): boolean` | `true` only on iOS 18+ with the native module loaded |
300
+ | `onAction(cb): () => void` | Fires when a **button** is tapped; `cb({ id, deepLink?, t })`. Returns an unsubscribe fn |
301
+ | `onStateChange<T>(key, cb): () => void` | Fires when a **toggle**'s `stateKey` changes. Returns an unsubscribe fn |
302
+ | `getState<T>(key): Promise<T \| null>` | Read App Group state (returns `null` off iOS) |
303
+ | `setState<T>(key, value): Promise<void>` | Write App Group state; reloads Control Center so the toggle re-renders |
304
+
305
+ ### `useControlState<T>(stateKey)`
306
+
307
+ React hook over a toggle's state — `const [value, setValue] = useControlState<boolean>('vpnEnabled')`.
308
+ First render is synchronous from a cache (no `null` flicker on cold start), then
309
+ stays in sync with both in-app `setValue` calls and Control Center taps.
310
+
311
+ ## Troubleshooting
312
+
313
+ - **Control doesn't appear in Control Center** — it's iOS 18+ only; add the
314
+ control via Control Center's edit screen (`+`). Confirm `expo prebuild` /
315
+ `rn-control-center generate` ran and the widget target is in your Xcode project.
316
+ - **Toggle doesn't sync with the app** — both targets must share the App Group.
317
+ The plugin/CLI generate the entitlement and inject `RNControlCenterAppGroup`
318
+ into the app `Info.plist`; if you changed the bundle id, re-run generation.
319
+ - **`cannot find 'ControlStore'` or App Group errors when building** — re-run
320
+ generation after changing controls, then `pod install`.
321
+ - **An icon renders blank** — the build prints a warning for unknown SF Symbol
322
+ names; check the spelling in SF Symbols.app.
323
+
324
+ ## Status
325
+
326
+ Week 8 (May 2026) — **v0.1.0 release prep — docs, license, examples, slimmed package** ✅ &nbsp; · &nbsp; **138 tests passing**
327
+
328
+ What Week 8 added:
329
+
330
+ - [x] **Docs** — Requirements, Installation, full API reference, and Troubleshooting sections (above)
331
+ - [x] **`examples/bare-rn`** — RN CLI example mirroring the Expo one (`rnControlCenter` config + `rn-control-center generate`)
332
+ - [x] **MIT `LICENSE`** file (the `license` field had no accompanying file)
333
+ - [x] **Slimmer tarball** — `files` no longer ships the source `.ts` that the built `lib/` already provides (≈108 kB → 71 kB packed); native sources, templates, CLI bin, and types are all still included
334
+ - [x] **`v0.1.0`** — `npm run build` + `npm pack` verified; `prepublishOnly` runs typecheck + tests + build
335
+
336
+ > Publishing to npm (`npm publish`) is the one remaining manual, irreversible step — run it when you're logged in (`npm whoami`).
337
+
338
+ ---
339
+
340
+ ## Earlier status
341
+
342
+ Week 7 (May 2026) — **Expo example + xcodebuild compile E2E** ✅ — a real host-app compile caught three bugs the JS tests couldn't: a Swift module boundary (native module in the Pod couldn't see the app-generated `ControlStore` → fixed with a Pod-side [`ControlStoreRuntime`](ios/ControlStoreRuntime.swift) reading config from `Info.plist`), a non-existent `hasListeners` member, and a too-high podspec deployment target. Also fixed `package.json` `main`/`types`.
343
+
344
+ Week 6 (May 2026) — **runtime hook polished + full SF Symbol set with build-time validation** ✅ &nbsp; · &nbsp; **136 tests passing**
345
+
346
+ What works today:
347
+
348
+ - [x] `defineControls({...})` types + `~200` curated SF Symbols literal union (autocomplete)
349
+ - [x] **Full SF Symbol set** (5,359 names, generated from [symbolist](https://github.com/marcbouchenoire/symbolist), MIT) — build-time spell-check warns on typos like `lock.filll` without blocking the build
350
+ - [x] **`useControlState` polish** — synchronous initial value from a JS cache seeded by a native `initialState` constant (no first-render `null` flicker on cold start); programmatic `setState` calls `ControlCenter.shared.reloadAllControls()` so the Control Center toggle re-renders immediately
351
+ - [x] Babel AST parser with literal-only policy and line-aware errors
352
+ - [x] Handlebars templates for **Button** + **Toggle** controls, intents, and `ControlStore.swift`
353
+ - [x] `generateNativeFiles()` — emits 8 Swift/plist/entitlement files tagged with target membership
354
+ - [x] `wireXcodeProject()` — mutates `project.pbxproj` to add the widget target, link frameworks, register the synced folder + ExceptionSets, and apply build settings on both targets
355
+ - [x] **Expo Config Plugin** (`plugin/index.ts`) wires the entire pipeline into `expo prebuild`
356
+ - [x] **Standalone CLI** (`npx rn-control-center generate`) runs the same pipeline for bare RN CLI projects
357
+ - [x] **End-to-end build validated:** in a real Expo app, `expo prebuild` produces a project that builds with `xcodebuild`, the control shows up in iOS Control Center, and tapping it opens the main app — the failure mode that bacons-based setups hit because they couldn't put the AppIntent in both targets is solved here by the ExceptionSet flow
358
+ - [x] **Native Module** (`ios/RNControlCenter.swift` + `.mm`) — Darwin notification observer with `Unmanaged` pointer trick, cold-start queue drain, App Group `UserDefaults` get/set exposed to JS via Promise. Legacy Bridge (`RCT_EXTERN_MODULE`); TurboModule migration planned for v0.2
359
+ - [x] **`.podspec`** — CocoaPods integration; library autolinks into a consumer RN app's `pod install`
360
+ - [x] **JS wrapper** (`src/ControlCenter.ts`) — `NativeEventEmitter` over the native module; `onAction` / `onStateChange` event subscriptions, `getState` / `setState` Promise-based; safe no-op on Android and pre-iOS-18
361
+
362
+ ---
363
+
364
+ ## Roadmap
365
+
366
+ | Week | Milestone | Status |
367
+ | ---- | --------- | ------ |
368
+ | 1 | Scaffold + AST parser + Button Swift templates | ✅ |
369
+ | 2 | Toggle template + ControlStore runtime + Info.plist / entitlement generation | ✅ |
370
+ | 3 | pbxproj target wiring (target add, framework link, membership, build settings) | ✅ |
371
+ | 4 | Expo Config Plugin + standalone CLI (`rn-control-center generate`) | ✅ |
372
+ | 5 | Native Module (Darwin notifications + App Group UserDefaults) | ✅ |
373
+ | 6 | Full SF Symbol set + validation + `useControlState` runtime (cache + reload) | ✅ |
374
+ | 7 | Expo example app + xcodebuild compile E2E (host app + extension link on real SDK) | ✅ |
375
+ | 8 | Documentation + RN CLI example + v0.1.0 release prep (publish = manual step) | ✅ |
376
+
377
+ v0.2+: Android Quick Settings Tiles for a unified cross-platform API, Lock Screen and Action Button control targets, dynamic intents.
378
+
379
+ ---
380
+
381
+ ## Development
382
+
383
+ ```bash
384
+ git clone https://github.com/alstjd8826/react-native-control-center.git
385
+ cd react-native-control-center
386
+ npm install --legacy-peer-deps
387
+
388
+ npm run typecheck # tsc --noEmit
389
+ npm test # jest, 138 tests
390
+ ```
391
+
392
+ The repo is structured as a publishable RN library plus the tooling that backs it:
393
+
394
+ ```
395
+ src/ → public API shipped to consumers
396
+ core/ → parser + codegen (shared by Expo plugin and CLI)
397
+ plugin/ → Expo Config Plugin entry point
398
+ cli/ → standalone `rn-control-center` binary
399
+ ios/ → native module sources
400
+ scripts/ → tooling (e.g. gen-sf-symbols.mjs regenerates the symbol list)
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Design notes
406
+
407
+ - **Literal-only configs.** `defineControls({...})` must contain literal values only; variable references and function calls are rejected at parse time. This lets codegen run without ever executing user code.
408
+ - **Independent of `@bacons/apple-targets`.** Bacons is a great general-purpose target plugin, but was tripped up by `@expo/prebuild-config` path changes in Expo SDK 54 during early prototyping. This library talks to `pbxproj` directly through the `xcode` npm package for a narrower, more stable surface.
409
+ - **App Group–backed state.** Toggle state lives in a suite UserDefaults shared between the main app and the widget extension; the library generates the entitlement and provisions a sensible default group ID.
410
+
411
+ ---
412
+
413
+ ## License
414
+
415
+ MIT
package/app.plugin.js ADDED
@@ -0,0 +1,14 @@
1
+ // Expo가 plugins 배열에서 패키지 이름만 봤을 때 이 파일을 찾도록.
2
+ // 빌드된 lib/ 결과물이 있으면 거길 보고, 없으면 src를 ts-node 처리.
3
+ const path = require('path');
4
+
5
+ let plugin;
6
+ try {
7
+ plugin = require(path.join(__dirname, 'lib', 'commonjs', 'plugin')).default;
8
+ } catch {
9
+ // 로컬 개발 (라이브러리 빌드 전) — TS 직접 실행
10
+ require('ts-node/register');
11
+ plugin = require(path.join(__dirname, 'plugin', 'index')).default;
12
+ }
13
+
14
+ module.exports = plugin;
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable @typescript-eslint/no-var-requires */
3
+
4
+ const path = require('path');
5
+
6
+ let runGenerate;
7
+ try {
8
+ // production: 빌드된 lib에서 로드
9
+ ({ runGenerate } = require(path.join(__dirname, '..', '..', 'lib', 'commonjs', 'cli', 'runGenerate')));
10
+ } catch {
11
+ // 로컬 개발: TS 직접 실행
12
+ require('ts-node/register');
13
+ ({ runGenerate } = require(path.join(__dirname, '..', 'runGenerate')));
14
+ }
15
+
16
+ const command = process.argv[2];
17
+
18
+ if (!command || command === 'help' || command === '--help') {
19
+ console.log(
20
+ [
21
+ 'Usage: rn-control-center <command>',
22
+ '',
23
+ 'Commands:',
24
+ ' generate Generate widget extension files into ios/ and modify pbxproj',
25
+ '',
26
+ 'Reads configuration from your project package.json:',
27
+ ' {',
28
+ ' "rnControlCenter": {',
29
+ ' "controls": "./src/controls.ts",',
30
+ ' "urlScheme": "myapp"',
31
+ ' }',
32
+ ' }',
33
+ ].join('\n')
34
+ );
35
+ process.exit(0);
36
+ }
37
+
38
+ if (command === 'generate') {
39
+ try {
40
+ const result = runGenerate();
41
+ console.log(`✓ Wrote ${result.filesWritten.length} files`);
42
+ console.log(`✓ Updated ${result.pbxprojPath}`);
43
+ console.log(' Widget target: ' + result.widgetTargetUuid);
44
+ console.log(' Main app target:' + result.mainAppTargetUuid);
45
+ } catch (err) {
46
+ console.error(err.message ?? err);
47
+ process.exit(1);
48
+ }
49
+ } else {
50
+ console.error(`Unknown command: ${command}`);
51
+ process.exit(2);
52
+ }
@@ -0,0 +1,81 @@
1
+ import Foundation
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────
4
+ // 📄 ControlStoreRuntime.swift (라이브러리가 Pod으로 배송 — 메인 앱 쪽)
5
+ //
6
+ // 왜 이게 따로 있나:
7
+ // 위젯 익스텐션에는 codegen이 만든 `ControlStore.swift`가 들어간다. 하지만 그
8
+ // 파일은 "앱/위젯 타겟"에 속하고, Native Module(RNControlCenter)은 별도의
9
+ // "Pod 모듈"로 컴파일되기 때문에 서로의 클래스를 볼 수 없다
10
+ // (Swift 모듈 경계). 그래서 메인 앱 쪽에서 쓸 런타임 스토어를 라이브러리가
11
+ // 직접 배송한다.
12
+ //
13
+ // 생성된 ControlStore와 "같은 사물함"을 바라봐야 하므로:
14
+ // • App Group ID → 메인 앱 Info.plist의 "RNControlCenterAppGroup" 에서 읽음
15
+ // (Expo 플러그인 / CLI가 codegen과 동일한 값으로 주입)
16
+ // • Darwin 이름 → "<appGroup>.event" (생성 코드와 동일 규칙)
17
+ // • 큐 키 → "__rncc.actionQueue" / "__rncc.stateChangeQueue" (동일 상수)
18
+ // • stateKeys → Info.plist "RNControlCenterStateKeys" (snapshot 정제용)
19
+ // ─────────────────────────────────────────────────────────────────────────
20
+
21
+ public final class ControlStoreRuntime {
22
+ public static let shared = ControlStoreRuntime()
23
+
24
+ // 생성된 ControlStore와 반드시 일치해야 하는 큐 키.
25
+ private static let actionQueueKey = "__rncc.actionQueue"
26
+ private static let stateChangeQueueKey = "__rncc.stateChangeQueue"
27
+
28
+ public let appGroupId: String
29
+ public let darwinNotificationName: String
30
+ public let stateKeys: [String]
31
+
32
+ private let defaults: UserDefaults
33
+
34
+ private init() {
35
+ let info = Bundle.main.infoDictionary
36
+ let group = (info?["RNControlCenterAppGroup"] as? String) ?? ""
37
+ self.appGroupId = group
38
+ self.stateKeys = (info?["RNControlCenterStateKeys"] as? [String]) ?? []
39
+ self.darwinNotificationName = group.isEmpty ? "rncc.event" : "\(group).event"
40
+
41
+ // App Group이 비어있거나 entitlement 미설정이면 표준 defaults로 폴백.
42
+ // (위젯이 안 보이는 환경 등 — 라이브러리가 크래시 없이 no-op 되도록)
43
+ self.defaults = UserDefaults(suiteName: group) ?? .standard
44
+ }
45
+
46
+ // ─── Toggle 상태 R/W (useControlState 훅이 사용) ──────────────────────
47
+
48
+ public func getBool(_ key: String) -> Bool {
49
+ return defaults.bool(forKey: key)
50
+ }
51
+
52
+ public func setBool(_ key: String, value: Bool) {
53
+ defaults.set(value, forKey: key)
54
+ }
55
+
56
+ /// 선언된 stateKey들의 현재 값 스냅샷. constantsToExport(initialState)로 전달돼
57
+ /// JS 캐시를 콜드 스타트 시점에 채운다.
58
+ public func snapshot() -> [String: Any] {
59
+ var out: [String: Any] = [:]
60
+ for key in stateKeys {
61
+ if let value = defaults.object(forKey: key) {
62
+ out[key] = value
63
+ }
64
+ }
65
+ return out
66
+ }
67
+
68
+ // ─── 이벤트 큐 drain (위젯이 enqueue → 앱이 dequeue) ──────────────────
69
+
70
+ public func dequeueActionEvents() -> [[String: Any]] {
71
+ let events = (defaults.array(forKey: Self.actionQueueKey) as? [[String: Any]]) ?? []
72
+ defaults.removeObject(forKey: Self.actionQueueKey)
73
+ return events
74
+ }
75
+
76
+ public func dequeueStateChangeEvents() -> [[String: Any]] {
77
+ let events = (defaults.array(forKey: Self.stateChangeQueueKey) as? [[String: Any]]) ?? []
78
+ defaults.removeObject(forKey: Self.stateChangeQueueKey)
79
+ return events
80
+ }
81
+ }
@@ -0,0 +1,28 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // 📄 RNControlCenter.mm
3
+ // Objective-C 브릿지 — Swift로 작성한 RNControlCenter 클래스를
4
+ // React Native의 Legacy Bridge에 "Native Module"로 등록한다.
5
+ //
6
+ // .swift 파일이 클래스를 ObjC 런타임에 "노출"한다면,
7
+ // 이 파일은 그것을 RN 브릿지에 "신청"하는 서류 역할.
8
+ // ─────────────────────────────────────────────────────────────────────────
9
+
10
+ #import <React/RCTBridgeModule.h>
11
+ #import <React/RCTEventEmitter.h>
12
+
13
+ // 클래스 등록 — Swift의 @objc(RNControlCenter)와 이름이 일치해야 함.
14
+ // 두 번째 인자 RCTEventEmitter는 부모 클래스를 RN에게 알려주는 정보.
15
+ @interface RCT_EXTERN_MODULE(RNControlCenter, RCTEventEmitter)
16
+
17
+ // 메서드 등록 — Swift 쪽 @objc func 시그니처와 1:1 매칭.
18
+
19
+ RCT_EXTERN_METHOD(getState:(NSString *)key
20
+ resolver:(RCTPromiseResolveBlock)resolve
21
+ rejecter:(RCTPromiseRejectBlock)reject)
22
+
23
+ RCT_EXTERN_METHOD(setState:(NSString *)key
24
+ value:(BOOL)value
25
+ resolver:(RCTPromiseResolveBlock)resolve
26
+ rejecter:(RCTPromiseRejectBlock)reject)
27
+
28
+ @end