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
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
|
+
  
|
|
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** ✅ · **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** ✅ · **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
|