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,43 @@
|
|
|
1
|
+
import type { ConfigPlugin } from '@expo/config-plugins';
|
|
2
|
+
import type { ParsedControl } from '../core/types';
|
|
3
|
+
import type { NativeFile } from '../core/generate';
|
|
4
|
+
export interface ControlCenterPluginProps {
|
|
5
|
+
/** controls.ts 파일 경로 (사용자 프로젝트 루트 기준) */
|
|
6
|
+
controls: string;
|
|
7
|
+
/** 딥링크용 URL scheme. 예: "myapp" */
|
|
8
|
+
urlScheme: string;
|
|
9
|
+
/** App Group ID. 기본 "group.{bundleId}.controls" */
|
|
10
|
+
appGroupId?: string;
|
|
11
|
+
/** Widget Extension 폴더/타겟 이름. 기본 "ControlCenterExtension" */
|
|
12
|
+
extensionName?: string;
|
|
13
|
+
/** 위젯 deployment target. 기본 "18.0" */
|
|
14
|
+
deploymentTarget?: string;
|
|
15
|
+
/** Swift 버전. 기본 "5.0" */
|
|
16
|
+
swiftVersion?: string;
|
|
17
|
+
}
|
|
18
|
+
declare const withControlCenter: ConfigPlugin<ControlCenterPluginProps>;
|
|
19
|
+
/**
|
|
20
|
+
* generateNativeFiles 출력에서 'shared' 라벨 파일들을 추출해
|
|
21
|
+
* extensionName/ 접두 부분을 제거한 상대 경로 리스트로 반환.
|
|
22
|
+
*
|
|
23
|
+
* 예: "ControlCenterExtension/Intents/X.swift" → "Intents/X.swift"
|
|
24
|
+
*/
|
|
25
|
+
declare function deriveSharedFiles(files: NativeFile[], extensionName: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* 단위 테스트용 — mod 안에서 일어나는 핵심 로직(controls 파싱 + 파일 생성 + 디스크 쓰기)을
|
|
28
|
+
* Expo의 mod 시스템 없이 직접 호출 가능하게 추출.
|
|
29
|
+
*/
|
|
30
|
+
export declare function generateAndWriteFiles(opts: {
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
platformRoot: string;
|
|
33
|
+
bundleId: string;
|
|
34
|
+
controls: string;
|
|
35
|
+
urlScheme: string;
|
|
36
|
+
appGroupId?: string;
|
|
37
|
+
extensionName?: string;
|
|
38
|
+
}): {
|
|
39
|
+
files: NativeFile[];
|
|
40
|
+
controls: ParsedControl[];
|
|
41
|
+
};
|
|
42
|
+
export default withControlCenter;
|
|
43
|
+
export { deriveSharedFiles };
|
|
@@ -0,0 +1,177 @@
|
|
|
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.generateAndWriteFiles = generateAndWriteFiles;
|
|
37
|
+
exports.deriveSharedFiles = deriveSharedFiles;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
41
|
+
const parseControls_1 = require("../core/parseControls");
|
|
42
|
+
const validateSymbols_1 = require("../core/validateSymbols");
|
|
43
|
+
const generate_1 = require("../core/generate");
|
|
44
|
+
const swift_1 = require("../core/generate/swift");
|
|
45
|
+
const wire_1 = require("../core/xcode/wire");
|
|
46
|
+
const withControlCenter = (config, props) => {
|
|
47
|
+
validateProps(props);
|
|
48
|
+
const extensionName = props.extensionName ?? 'ControlCenterExtension';
|
|
49
|
+
// 두 mod가 공유할 상태. 첫 mod에서 채우고, 두 번째 mod에서 사용.
|
|
50
|
+
let cachedFiles = null;
|
|
51
|
+
let cachedControls = null;
|
|
52
|
+
// Step 1: 사용자 controls.ts 읽고 → 8개 파일을 ios/ 에 쓴다.
|
|
53
|
+
config = (0, config_plugins_1.withDangerousMod)(config, [
|
|
54
|
+
'ios',
|
|
55
|
+
async (cfg) => {
|
|
56
|
+
const projectRoot = cfg.modRequest.projectRoot;
|
|
57
|
+
const platformRoot = cfg.modRequest.platformProjectRoot;
|
|
58
|
+
const bundleId = cfg.ios?.bundleIdentifier;
|
|
59
|
+
if (!bundleId) {
|
|
60
|
+
throw new Error('[react-native-control-center] ios.bundleIdentifier must be set in app.json.');
|
|
61
|
+
}
|
|
62
|
+
const controlsAbs = path.resolve(projectRoot, props.controls);
|
|
63
|
+
if (!fs.existsSync(controlsAbs)) {
|
|
64
|
+
throw new Error(`[react-native-control-center] controls file not found: ${controlsAbs}`);
|
|
65
|
+
}
|
|
66
|
+
cachedControls = (0, parseControls_1.parseControlsFile)(controlsAbs);
|
|
67
|
+
(0, validateSymbols_1.warnUnknownSymbols)(cachedControls); // 심볼 오타 경고 (빌드는 막지 않음)
|
|
68
|
+
cachedFiles = (0, generate_1.generateNativeFiles)({
|
|
69
|
+
controls: cachedControls,
|
|
70
|
+
bundleId,
|
|
71
|
+
urlScheme: props.urlScheme,
|
|
72
|
+
...(props.appGroupId !== undefined && { appGroupId: props.appGroupId }),
|
|
73
|
+
extensionName,
|
|
74
|
+
});
|
|
75
|
+
for (const file of cachedFiles) {
|
|
76
|
+
const fullPath = path.join(platformRoot, file.path);
|
|
77
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
78
|
+
fs.writeFileSync(fullPath, file.content);
|
|
79
|
+
}
|
|
80
|
+
return cfg;
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
// Step 1b: 메인 앱 Info.plist에 런타임 스토어가 읽을 키를 주입한다.
|
|
84
|
+
// Native Module(Pod 모듈)은 생성된 ControlStore(앱 모듈)를 볼 수 없으므로
|
|
85
|
+
// App Group ID와 stateKey 목록을 Info.plist를 통해 전달한다. 값은 codegen이
|
|
86
|
+
// 쓰는 것과 정확히 동일해야 양쪽이 같은 사물함/큐를 바라본다.
|
|
87
|
+
config = (0, config_plugins_1.withInfoPlist)(config, (cfg) => {
|
|
88
|
+
const bundleId = cfg.ios?.bundleIdentifier;
|
|
89
|
+
if (!bundleId)
|
|
90
|
+
return cfg; // 상단에서 이미 검증됨 — 방어적 처리
|
|
91
|
+
const appGroupId = props.appGroupId ?? (0, swift_1.defaultAppGroupId)(bundleId);
|
|
92
|
+
let controls = cachedControls;
|
|
93
|
+
if (!controls) {
|
|
94
|
+
// mod 실행 순서가 dangerous mod보다 앞설 경우를 대비해 한 번 더 파싱.
|
|
95
|
+
const controlsAbs = path.resolve(cfg.modRequest.projectRoot, props.controls);
|
|
96
|
+
controls = fs.existsSync(controlsAbs) ? (0, parseControls_1.parseControlsFile)(controlsAbs) : [];
|
|
97
|
+
}
|
|
98
|
+
cfg.modResults['RNControlCenterAppGroup'] = appGroupId;
|
|
99
|
+
cfg.modResults['RNControlCenterStateKeys'] = (0, generate_1.collectStateKeys)(controls);
|
|
100
|
+
return cfg;
|
|
101
|
+
});
|
|
102
|
+
// Step 2: pbxproj 변형 — wireXcodeProject 호출.
|
|
103
|
+
config = (0, config_plugins_1.withXcodeProject)(config, (cfg) => {
|
|
104
|
+
if (!cachedFiles) {
|
|
105
|
+
// dangerous mod가 먼저 돌아야 함. Expo의 mod 순서가 어긋나면 발생 가능.
|
|
106
|
+
throw new Error('[react-native-control-center] internal: file generation must run before pbxproj wiring.');
|
|
107
|
+
}
|
|
108
|
+
const bundleId = cfg.ios?.bundleIdentifier;
|
|
109
|
+
if (!bundleId) {
|
|
110
|
+
throw new Error('[react-native-control-center] ios.bundleIdentifier missing during pbxproj wiring.');
|
|
111
|
+
}
|
|
112
|
+
const sharedFiles = deriveSharedFiles(cachedFiles, extensionName);
|
|
113
|
+
const widgetBundleId = `${bundleId}.${extensionName.toLowerCase()}`;
|
|
114
|
+
(0, wire_1.wireXcodeProject)(cfg.modResults, {
|
|
115
|
+
mainAppBundleId: bundleId,
|
|
116
|
+
widgetTargetName: extensionName,
|
|
117
|
+
widgetBundleId,
|
|
118
|
+
sharedFiles,
|
|
119
|
+
...(props.deploymentTarget !== undefined && {
|
|
120
|
+
deploymentTarget: props.deploymentTarget,
|
|
121
|
+
}),
|
|
122
|
+
...(props.swiftVersion !== undefined && { swiftVersion: props.swiftVersion }),
|
|
123
|
+
});
|
|
124
|
+
return cfg;
|
|
125
|
+
});
|
|
126
|
+
return config;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* generateNativeFiles 출력에서 'shared' 라벨 파일들을 추출해
|
|
130
|
+
* extensionName/ 접두 부분을 제거한 상대 경로 리스트로 반환.
|
|
131
|
+
*
|
|
132
|
+
* 예: "ControlCenterExtension/Intents/X.swift" → "Intents/X.swift"
|
|
133
|
+
*/
|
|
134
|
+
function deriveSharedFiles(files, extensionName) {
|
|
135
|
+
const prefix = `${extensionName}/`;
|
|
136
|
+
return files
|
|
137
|
+
.filter((f) => f.target === 'shared')
|
|
138
|
+
.map((f) => (f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path));
|
|
139
|
+
}
|
|
140
|
+
function validateProps(props) {
|
|
141
|
+
if (!props || typeof props !== 'object') {
|
|
142
|
+
throw new Error('[react-native-control-center] Plugin props are required. ' +
|
|
143
|
+
'Add ["react-native-control-center", { controls: "./src/controls.ts", urlScheme: "..." }] to app.json plugins.');
|
|
144
|
+
}
|
|
145
|
+
if (!props.controls || typeof props.controls !== 'string') {
|
|
146
|
+
throw new Error('[react-native-control-center] `controls` prop must be a path to your controls.ts file.');
|
|
147
|
+
}
|
|
148
|
+
if (!props.urlScheme || typeof props.urlScheme !== 'string') {
|
|
149
|
+
throw new Error('[react-native-control-center] `urlScheme` prop is required (e.g. "myapp").');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 단위 테스트용 — mod 안에서 일어나는 핵심 로직(controls 파싱 + 파일 생성 + 디스크 쓰기)을
|
|
154
|
+
* Expo의 mod 시스템 없이 직접 호출 가능하게 추출.
|
|
155
|
+
*/
|
|
156
|
+
function generateAndWriteFiles(opts) {
|
|
157
|
+
const extensionName = opts.extensionName ?? 'ControlCenterExtension';
|
|
158
|
+
const controlsAbs = path.resolve(opts.projectRoot, opts.controls);
|
|
159
|
+
if (!fs.existsSync(controlsAbs)) {
|
|
160
|
+
throw new Error(`[react-native-control-center] controls file not found: ${controlsAbs}`);
|
|
161
|
+
}
|
|
162
|
+
const controls = (0, parseControls_1.parseControlsFile)(controlsAbs);
|
|
163
|
+
const files = (0, generate_1.generateNativeFiles)({
|
|
164
|
+
controls,
|
|
165
|
+
bundleId: opts.bundleId,
|
|
166
|
+
urlScheme: opts.urlScheme,
|
|
167
|
+
...(opts.appGroupId !== undefined && { appGroupId: opts.appGroupId }),
|
|
168
|
+
extensionName,
|
|
169
|
+
});
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
const fullPath = path.join(opts.platformRoot, file.path);
|
|
172
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
173
|
+
fs.writeFileSync(fullPath, file.content);
|
|
174
|
+
}
|
|
175
|
+
return { files, controls };
|
|
176
|
+
}
|
|
177
|
+
exports.default = withControlCenter;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface ControlActionEvent {
|
|
2
|
+
id: string;
|
|
3
|
+
deepLink?: string;
|
|
4
|
+
t: number;
|
|
5
|
+
}
|
|
6
|
+
type Unsubscribe = () => void;
|
|
7
|
+
declare class ControlCenterAPI {
|
|
8
|
+
private emitter;
|
|
9
|
+
constructor();
|
|
10
|
+
/**
|
|
11
|
+
* 캐시된 마지막 값을 동기로 반환. 모르면 undefined.
|
|
12
|
+
* useControlState 훅이 첫 렌더 초기값으로 사용 (깜빡임 방지).
|
|
13
|
+
*/
|
|
14
|
+
getCachedState<T>(key: string): T | undefined;
|
|
15
|
+
/** 라이브러리가 현재 환경에서 실제로 동작 가능한지 (iOS + Native Module 로드됨). */
|
|
16
|
+
isAvailable(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* 사용자가 제어센터의 Button을 탭했을 때 발사되는 이벤트 구독.
|
|
19
|
+
* @returns unsubscribe 함수
|
|
20
|
+
*/
|
|
21
|
+
onAction(cb: (event: ControlActionEvent) => void): Unsubscribe;
|
|
22
|
+
/**
|
|
23
|
+
* 특정 stateKey의 값이 바뀌었을 때 발사되는 이벤트 구독.
|
|
24
|
+
* Swift는 모든 키를 하나의 이벤트로 발사하므로 여기서 키 필터링.
|
|
25
|
+
* @returns unsubscribe 함수
|
|
26
|
+
*/
|
|
27
|
+
onStateChange<T>(key: string, cb: (value: T) => void): Unsubscribe;
|
|
28
|
+
/** App Group UserDefaults에서 값 읽기. iOS 외에선 null. */
|
|
29
|
+
getState<T>(key: string): Promise<T | null>;
|
|
30
|
+
/** App Group UserDefaults에 값 쓰기. iOS 외에선 no-op. */
|
|
31
|
+
setState<T>(key: string, value: T): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export declare const ControlCenter: ControlCenterAPI;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ControlCenter = void 0;
|
|
4
|
+
const react_native_1 = require("react-native");
|
|
5
|
+
const stateCache_1 = require("./stateCache");
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// ControlCenter — Native Module JS wrapper
|
|
8
|
+
//
|
|
9
|
+
// Swift의 RNControlCenter가 발사하는 이벤트를 받고,
|
|
10
|
+
// 메서드 호출(getState/setState)을 Promise로 노출한다.
|
|
11
|
+
//
|
|
12
|
+
// iOS 외 플랫폼 또는 Native Module 미설치 시 모든 메서드는 no-op.
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
14
|
+
const RNControlCenter = react_native_1.NativeModules.RNControlCenter;
|
|
15
|
+
class ControlCenterAPI {
|
|
16
|
+
emitter;
|
|
17
|
+
constructor() {
|
|
18
|
+
if (react_native_1.Platform.OS === 'ios' && RNControlCenter) {
|
|
19
|
+
// NativeEventEmitter는 NativeModule을 받아 startObserving/stopObserving을
|
|
20
|
+
// 자동으로 호출해 준다. addListener가 첫 등록되는 순간 Swift의
|
|
21
|
+
// startObserving이 발사되고, 마지막 listener가 제거되면 stopObserving이 발사된다.
|
|
22
|
+
this.emitter = new react_native_1.NativeEventEmitter(react_native_1.NativeModules.RNControlCenter);
|
|
23
|
+
// 콜드 스타트 시드 — 네이티브가 넘겨준 초기 스냅샷으로 캐시를 미리 채운다.
|
|
24
|
+
(0, stateCache_1.seedCache)(RNControlCenter.initialState);
|
|
25
|
+
// 모든 ControlStateChange 이벤트를 캐시에 반영하는 내부 리스너.
|
|
26
|
+
// 키별 구독(onStateChange)과 별개로, 어떤 키가 바뀌든 캐시는 항상 최신.
|
|
27
|
+
this.emitter.addListener('ControlStateChange', (event) => {
|
|
28
|
+
(0, stateCache_1.setCachedState)(event.key, event.value);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
this.emitter = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 캐시된 마지막 값을 동기로 반환. 모르면 undefined.
|
|
37
|
+
* useControlState 훅이 첫 렌더 초기값으로 사용 (깜빡임 방지).
|
|
38
|
+
*/
|
|
39
|
+
getCachedState(key) {
|
|
40
|
+
return (0, stateCache_1.getCachedState)(key);
|
|
41
|
+
}
|
|
42
|
+
/** 라이브러리가 현재 환경에서 실제로 동작 가능한지 (iOS + Native Module 로드됨). */
|
|
43
|
+
isAvailable() {
|
|
44
|
+
return this.emitter !== null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 사용자가 제어센터의 Button을 탭했을 때 발사되는 이벤트 구독.
|
|
48
|
+
* @returns unsubscribe 함수
|
|
49
|
+
*/
|
|
50
|
+
onAction(cb) {
|
|
51
|
+
if (!this.emitter)
|
|
52
|
+
return () => { };
|
|
53
|
+
const sub = this.emitter.addListener('ControlAction', cb);
|
|
54
|
+
return () => sub.remove();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 특정 stateKey의 값이 바뀌었을 때 발사되는 이벤트 구독.
|
|
58
|
+
* Swift는 모든 키를 하나의 이벤트로 발사하므로 여기서 키 필터링.
|
|
59
|
+
* @returns unsubscribe 함수
|
|
60
|
+
*/
|
|
61
|
+
onStateChange(key, cb) {
|
|
62
|
+
if (!this.emitter)
|
|
63
|
+
return () => { };
|
|
64
|
+
const sub = this.emitter.addListener('ControlStateChange', (event) => {
|
|
65
|
+
if (event.key === key)
|
|
66
|
+
cb(event.value);
|
|
67
|
+
});
|
|
68
|
+
return () => sub.remove();
|
|
69
|
+
}
|
|
70
|
+
/** App Group UserDefaults에서 값 읽기. iOS 외에선 null. */
|
|
71
|
+
async getState(key) {
|
|
72
|
+
if (!RNControlCenter)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const value = await RNControlCenter.getState(key);
|
|
76
|
+
(0, stateCache_1.setCachedState)(key, value); // 응답을 캐시에 반영
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** App Group UserDefaults에 값 쓰기. iOS 외에선 no-op. */
|
|
84
|
+
async setState(key, value) {
|
|
85
|
+
(0, stateCache_1.setCachedState)(key, value); // optimistic — 네이티브 왕복 전에 캐시 먼저 갱신
|
|
86
|
+
if (!RNControlCenter)
|
|
87
|
+
return;
|
|
88
|
+
await RNControlCenter.setState(key, value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
exports.ControlCenter = new ControlCenterAPI();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defineControls = defineControls;
|
|
4
|
+
/**
|
|
5
|
+
* 컨트롤 선언. 이 함수는 타입 헬퍼일 뿐, 런타임에 아무 동작도 하지 않습니다.
|
|
6
|
+
* 실제 처리는 빌드 타임에 AST 파싱으로 이뤄집니다.
|
|
7
|
+
*/
|
|
8
|
+
function defineControls(controls) {
|
|
9
|
+
return controls;
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Group에 저장된 control 상태에 React 친화적 접근을 제공.
|
|
3
|
+
*
|
|
4
|
+
* Week 6: 캐시 레이어로 sync 초기값 보장.
|
|
5
|
+
* 첫 렌더에서 ControlCenter의 캐시(네이티브 initialState 시드 + 직전 값)를
|
|
6
|
+
* 동기로 읽어 깜빡임을 없앤다. 캐시에 없으면 null로 시작해 getState 응답을 기다림.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useControlState<T>(key: string): [T | null, (value: T) => void];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useControlState = useControlState;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const ControlCenter_1 = require("./ControlCenter");
|
|
6
|
+
/**
|
|
7
|
+
* App Group에 저장된 control 상태에 React 친화적 접근을 제공.
|
|
8
|
+
*
|
|
9
|
+
* Week 6: 캐시 레이어로 sync 초기값 보장.
|
|
10
|
+
* 첫 렌더에서 ControlCenter의 캐시(네이티브 initialState 시드 + 직전 값)를
|
|
11
|
+
* 동기로 읽어 깜빡임을 없앤다. 캐시에 없으면 null로 시작해 getState 응답을 기다림.
|
|
12
|
+
*/
|
|
13
|
+
function useControlState(key) {
|
|
14
|
+
const [value, setValue] = (0, react_1.useState)(() => {
|
|
15
|
+
const cached = ControlCenter_1.ControlCenter.getCachedState(key);
|
|
16
|
+
return cached !== undefined ? cached : null;
|
|
17
|
+
});
|
|
18
|
+
// 초기값 — native에 비동기로 물어봄 (캐시가 오래됐을 수 있으니 최신값으로 보정)
|
|
19
|
+
(0, react_1.useEffect)(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
ControlCenter_1.ControlCenter.getState(key).then((v) => {
|
|
22
|
+
if (!cancelled)
|
|
23
|
+
setValue(v);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, [key]);
|
|
29
|
+
// 변경 이벤트 구독
|
|
30
|
+
(0, react_1.useEffect)(() => {
|
|
31
|
+
return ControlCenter_1.ControlCenter.onStateChange(key, (newVal) => setValue(newVal));
|
|
32
|
+
}, [key]);
|
|
33
|
+
const setter = (0, react_1.useCallback)((newVal) => {
|
|
34
|
+
ControlCenter_1.ControlCenter.setState(key, newVal);
|
|
35
|
+
setValue(newVal); // optimistic update — 다음 이벤트가 같은 값을 다시 보내도 무해
|
|
36
|
+
}, [key]);
|
|
37
|
+
return [value, setter];
|
|
38
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { defineControls } from './defineControls';
|
|
2
|
+
export { ControlCenter } from './ControlCenter';
|
|
3
|
+
export { useControlState } from './hooks';
|
|
4
|
+
export type { ButtonControl, ToggleControl, Control, SFSymbolName, StrictSFSymbolName, HexColor, } from './types';
|
|
5
|
+
export type { KnownSFSymbol } from './sf-symbols';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useControlState = exports.ControlCenter = exports.defineControls = void 0;
|
|
4
|
+
var defineControls_1 = require("./defineControls");
|
|
5
|
+
Object.defineProperty(exports, "defineControls", { enumerable: true, get: function () { return defineControls_1.defineControls; } });
|
|
6
|
+
var ControlCenter_1 = require("./ControlCenter");
|
|
7
|
+
Object.defineProperty(exports, "ControlCenter", { enumerable: true, get: function () { return ControlCenter_1.ControlCenter; } });
|
|
8
|
+
var hooks_1 = require("./hooks");
|
|
9
|
+
Object.defineProperty(exports, "useControlState", { enumerable: true, get: function () { return hooks_1.useControlState; } });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 자주 쓰이는 SF Symbols 큐레이션 리스트.
|
|
3
|
+
* 자동완성용 — 실제 타입은 string도 허용합니다.
|
|
4
|
+
*
|
|
5
|
+
* 전체 5000+ 심볼은 https://developer.apple.com/sf-symbols/ 참조.
|
|
6
|
+
* Xcode → SF Symbols.app 에서 원하는 심볼 이름 검색 가능.
|
|
7
|
+
*/
|
|
8
|
+
export type KnownSFSymbol = 'square.and.pencil' | 'pencil' | 'pencil.circle' | 'pencil.circle.fill' | 'plus' | 'plus.circle' | 'plus.circle.fill' | 'minus' | 'minus.circle' | 'minus.circle.fill' | 'xmark' | 'xmark.circle' | 'xmark.circle.fill' | 'checkmark' | 'checkmark.circle' | 'checkmark.circle.fill' | 'trash' | 'trash.fill' | 'chevron.left' | 'chevron.right' | 'chevron.up' | 'chevron.down' | 'arrow.left' | 'arrow.right' | 'arrow.up' | 'arrow.down' | 'arrow.clockwise' | 'arrow.counterclockwise' | 'square.and.arrow.up' | 'square.and.arrow.down' | 'paperplane' | 'paperplane.fill' | 'envelope' | 'envelope.fill' | 'bubble.left' | 'bubble.right' | 'phone' | 'phone.fill' | 'play' | 'play.fill' | 'play.circle' | 'play.circle.fill' | 'pause' | 'pause.fill' | 'pause.circle' | 'pause.circle.fill' | 'stop' | 'stop.fill' | 'forward' | 'forward.fill' | 'backward' | 'backward.fill' | 'speaker' | 'speaker.fill' | 'speaker.slash' | 'speaker.slash.fill' | 'mic' | 'mic.fill' | 'mic.slash' | 'gear' | 'gearshape' | 'gearshape.fill' | 'power' | 'bolt' | 'bolt.fill' | 'bolt.slash' | 'bolt.slash.fill' | 'battery.100' | 'battery.75' | 'battery.50' | 'battery.25' | 'battery.0' | 'lock' | 'lock.fill' | 'lock.open' | 'lock.open.fill' | 'lock.shield' | 'lock.shield.fill' | 'key' | 'key.fill' | 'shield' | 'shield.fill' | 'shield.slash' | 'eye' | 'eye.fill' | 'eye.slash' | 'eye.slash.fill' | 'person' | 'person.fill' | 'person.circle' | 'person.circle.fill' | 'person.2' | 'person.2.fill' | 'person.3' | 'person.crop.circle' | 'person.crop.circle.fill' | 'doc' | 'doc.fill' | 'doc.text' | 'doc.text.fill' | 'note' | 'note.text' | 'folder' | 'folder.fill' | 'book' | 'book.fill' | 'bookmark' | 'bookmark.fill' | 'tag' | 'tag.fill' | 'heart' | 'heart.fill' | 'heart.slash' | 'star' | 'star.fill' | 'star.slash' | 'flag' | 'flag.fill' | 'flag.slash' | 'bell' | 'bell.fill' | 'bell.slash' | 'bell.slash.fill' | 'wifi' | 'wifi.slash' | 'antenna.radiowaves.left.and.right' | 'airplane' | 'airplayaudio' | 'airplayvideo' | 'network' | 'globe' | 'location' | 'location.fill' | 'location.slash' | 'mappin' | 'mappin.circle.fill' | 'map' | 'map.fill' | 'house' | 'house.fill' | 'building' | 'building.2' | 'clock' | 'clock.fill' | 'alarm' | 'alarm.fill' | 'timer' | 'stopwatch' | 'stopwatch.fill' | 'calendar' | 'calendar.badge.plus' | 'calendar.badge.clock' | 'camera' | 'camera.fill' | 'camera.circle' | 'camera.circle.fill' | 'photo' | 'photo.fill' | 'photo.on.rectangle' | 'video' | 'video.fill' | 'video.slash' | 'creditcard' | 'creditcard.fill' | 'cart' | 'cart.fill' | 'bag' | 'bag.fill' | 'dollarsign.circle' | 'dollarsign.circle.fill' | 'magnifyingglass' | 'magnifyingglass.circle' | 'info' | 'info.circle' | 'info.circle.fill' | 'questionmark' | 'questionmark.circle' | 'questionmark.circle.fill' | 'exclamationmark' | 'exclamationmark.circle' | 'exclamationmark.triangle' | 'exclamationmark.triangle.fill' | 'heart.text.square' | 'figure.walk' | 'figure.run' | 'figure.stand' | 'flame' | 'flame.fill' | 'drop' | 'drop.fill' | 'leaf' | 'leaf.fill' | 'sun.max' | 'sun.max.fill' | 'moon' | 'moon.fill' | 'moon.stars' | 'moon.stars.fill' | 'cloud' | 'cloud.fill' | 'cloud.rain' | 'cloud.rain.fill' | 'snowflake' | 'tv' | 'tv.fill' | 'lightbulb' | 'lightbulb.fill' | 'lightbulb.slash' | 'thermometer' | 'fan' | 'air.conditioner.horizontal' | 'rectangle.stack' | 'square.stack' | 'square.grid.2x2' | 'square.grid.3x3' | 'list.bullet' | 'list.dash' | 'ellipsis' | 'ellipsis.circle' | 'line.3.horizontal' | 'line.3.horizontal.decrease' | 'slider.horizontal.3' | 'arrow.up.arrow.down';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 캐시된 마지막 값을 동기로 반환. 한 번도 본 적 없으면 undefined. */
|
|
2
|
+
export declare function getCachedState<T>(key: string): T | undefined;
|
|
3
|
+
/** 새 값을 캐시에 기록 (getState 응답 / setState / onStateChange 이벤트에서 호출). */
|
|
4
|
+
export declare function setCachedState(key: string, value: unknown): void;
|
|
5
|
+
/** 네이티브 initialState 스냅샷으로 캐시를 한 번에 채움 (콜드 스타트 시드). */
|
|
6
|
+
export declare function seedCache(initial: Record<string, unknown> | undefined): void;
|
|
7
|
+
/** 테스트 전용 — 캐시를 비운다. */
|
|
8
|
+
export declare function __resetCacheForTests(): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// stateCache — control 상태의 동기 보관함 (Week 6)
|
|
4
|
+
//
|
|
5
|
+
// getState는 Promise라 값이 "다음 tick"에 도착한다. 그 사이 UI가 null로
|
|
6
|
+
// 깜빡이는 걸 막기 위해, 마지막으로 알던 값을 모듈 레벨 Map에 둔다.
|
|
7
|
+
// useControlState 훅이 첫 렌더에서 이 캐시를 동기로 읽어 바로 표시한다.
|
|
8
|
+
//
|
|
9
|
+
// react-native에 의존하지 않는 순수 모듈 — 그래서 node 환경에서 단독 테스트 가능.
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.getCachedState = getCachedState;
|
|
13
|
+
exports.setCachedState = setCachedState;
|
|
14
|
+
exports.seedCache = seedCache;
|
|
15
|
+
exports.__resetCacheForTests = __resetCacheForTests;
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
/** 캐시된 마지막 값을 동기로 반환. 한 번도 본 적 없으면 undefined. */
|
|
18
|
+
function getCachedState(key) {
|
|
19
|
+
return cache.has(key) ? cache.get(key) : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** 새 값을 캐시에 기록 (getState 응답 / setState / onStateChange 이벤트에서 호출). */
|
|
22
|
+
function setCachedState(key, value) {
|
|
23
|
+
cache.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
/** 네이티브 initialState 스냅샷으로 캐시를 한 번에 채움 (콜드 스타트 시드). */
|
|
26
|
+
function seedCache(initial) {
|
|
27
|
+
if (!initial)
|
|
28
|
+
return;
|
|
29
|
+
for (const key of Object.keys(initial)) {
|
|
30
|
+
cache.set(key, initial[key]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 테스트 전용 — 캐시를 비운다. */
|
|
34
|
+
function __resetCacheForTests() {
|
|
35
|
+
cache.clear();
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { KnownSFSymbol } from './sf-symbols';
|
|
2
|
+
export type HexColor = `#${string}`;
|
|
3
|
+
/**
|
|
4
|
+
* 유연 모드: 큐레이션된 ~200개는 자동완성 + 그 외 문자열도 허용.
|
|
5
|
+
* TypeScript 트릭: `(string & {})` 는 자동완성을 비활성화하지 않으면서
|
|
6
|
+
* 임의 문자열을 받게 해줌.
|
|
7
|
+
*/
|
|
8
|
+
export type SFSymbolName = KnownSFSymbol | (string & {});
|
|
9
|
+
/**
|
|
10
|
+
* 엄격 모드: 큐레이션 리스트에 있는 심볼만 허용.
|
|
11
|
+
* 오타/잘못된 이름 방지가 중요할 때 사용.
|
|
12
|
+
*/
|
|
13
|
+
export type StrictSFSymbolName = KnownSFSymbol;
|
|
14
|
+
export interface ButtonControl {
|
|
15
|
+
type: 'button';
|
|
16
|
+
title: string;
|
|
17
|
+
icon: SFSymbolName;
|
|
18
|
+
tint?: HexColor;
|
|
19
|
+
description?: string;
|
|
20
|
+
deepLink?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ToggleControl {
|
|
23
|
+
type: 'toggle';
|
|
24
|
+
title: string;
|
|
25
|
+
icons: {
|
|
26
|
+
on: SFSymbolName;
|
|
27
|
+
off: SFSymbolName;
|
|
28
|
+
};
|
|
29
|
+
tint?: {
|
|
30
|
+
on: HexColor;
|
|
31
|
+
off: HexColor;
|
|
32
|
+
};
|
|
33
|
+
stateKey: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
}
|
|
36
|
+
export type Control = ButtonControl | ToggleControl;
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-control-center",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "iOS 18+ Control Center custom controls for React Native — declare in TypeScript, zero Swift required.",
|
|
5
|
+
"main": "lib/commonjs/src/index.js",
|
|
6
|
+
"types": "lib/commonjs/src/index.d.ts",
|
|
7
|
+
"react-native": "src/index.ts",
|
|
8
|
+
"source": "src/index.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"lib",
|
|
12
|
+
"ios",
|
|
13
|
+
"cli/bin",
|
|
14
|
+
"app.plugin.js",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"!**/__tests__",
|
|
17
|
+
"!**/__fixtures__",
|
|
18
|
+
"!**/*.test.*",
|
|
19
|
+
"!**/.DS_Store"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"rn-control-center": "cli/bin/rn-control-center.js"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "jest",
|
|
27
|
+
"test:watch": "jest --watch",
|
|
28
|
+
"build": "rm -rf lib && tsc -p tsconfig.build.json && cp -R core/templates lib/commonjs/core/templates",
|
|
29
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"react-native",
|
|
33
|
+
"ios",
|
|
34
|
+
"control-center",
|
|
35
|
+
"control-widget",
|
|
36
|
+
"widget",
|
|
37
|
+
"expo",
|
|
38
|
+
"expo-config-plugin",
|
|
39
|
+
"ios18",
|
|
40
|
+
"app-intents"
|
|
41
|
+
],
|
|
42
|
+
"author": "darby <darby@glorang.com>",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": ">=18.0.0",
|
|
46
|
+
"react-native": ">=0.74.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"expo": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@babel/parser": "^7.26.0",
|
|
55
|
+
"@babel/traverse": "^7.26.0",
|
|
56
|
+
"@expo/config-plugins": "^9.0.0",
|
|
57
|
+
"handlebars": "^4.7.8",
|
|
58
|
+
"plist": "^3.1.0",
|
|
59
|
+
"xcode": "^3.0.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@babel/core": "^7.26.0",
|
|
63
|
+
"@types/babel__traverse": "^7.20.6",
|
|
64
|
+
"@types/jest": "^29.5.14",
|
|
65
|
+
"@types/node": "^22.10.1",
|
|
66
|
+
"@types/plist": "^3.0.5",
|
|
67
|
+
"@types/react": "^19.0.0",
|
|
68
|
+
"jest": "^29.7.0",
|
|
69
|
+
"react": "19.0.0",
|
|
70
|
+
"react-native": "0.81.5",
|
|
71
|
+
"react-native-builder-bob": "^0.35.0",
|
|
72
|
+
"ts-jest": "^29.2.5",
|
|
73
|
+
"typescript": "^5.7.2"
|
|
74
|
+
}
|
|
75
|
+
}
|