leiao-brand 1.0.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/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # leiao-brand
2
+
3
+ 在客户自己的 React Native 工程里,用图形页改桌面图标、显示名、iOS Bundle ID、Android 包名。不经过 SaaS 控制台。
4
+
5
+ ```bash
6
+ cd 你的RN工程
7
+ npx leiao-brand
8
+ ```
9
+
10
+ 浏览器打开后点「写进工程」。需要 Node ≥ 22.11,工程里要有 `ios/` 和 `android/`。
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import {spawnSync} from 'node:child_process';
3
+ import {fileURLToPath} from 'node:url';
4
+
5
+ const cli = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
6
+ const result = spawnSync(process.execPath, ['--import', 'tsx', cli, ...process.argv.slice(2)], {
7
+ stdio: 'inherit',
8
+ });
9
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "leiao-brand",
3
+ "version": "1.0.0",
4
+ "description": "Local graphical branding for a React Native project",
5
+ "license": "MIT",
6
+ "author": "lao408349",
7
+ "type": "module",
8
+ "bin": {
9
+ "leiao-brand": "bin/leiao-brand.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "public",
14
+ "src",
15
+ "!src/test"
16
+ ],
17
+ "scripts": {
18
+ "start": "tsx src/cli.ts",
19
+ "test": "tsx --test src/test/*.test.ts"
20
+ },
21
+ "engines": {
22
+ "node": ">=22.11.0"
23
+ },
24
+ "dependencies": {
25
+ "sharp": "^0.35.4",
26
+ "tsx": "^4.21.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.10.1",
30
+ "typescript": "^5.9.3"
31
+ }
32
+ }
@@ -0,0 +1,249 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>改桌面外观</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --text: #1f2329;
11
+ --muted: #646a73;
12
+ --line: rgba(31, 35, 41, 0.08);
13
+ --fill: #f4f5f7;
14
+ --bg: #f7f8fa;
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ font: 14px/22px "PingFang SC", "Noto Sans SC", -apple-system, sans-serif;
23
+ }
24
+ main {
25
+ display: grid;
26
+ grid-template-columns: 176px minmax(0, 1fr);
27
+ gap: 32px;
28
+ max-width: 720px;
29
+ margin: 0 auto;
30
+ padding: 32px 20px 48px;
31
+ }
32
+ h1 { font-size: 18px; margin: 0 0 6px; }
33
+ .lead { color: var(--muted); margin: 0 0 20px; }
34
+ .phone {
35
+ width: 176px;
36
+ height: 334px;
37
+ padding: 16px 14px;
38
+ border-radius: 32px;
39
+ background: #111318;
40
+ box-shadow: 0 16px 40px rgba(17, 19, 24, 0.18);
41
+ }
42
+ .screen {
43
+ height: 100%;
44
+ padding: 28px 10px 16px;
45
+ border-radius: 22px;
46
+ background:
47
+ radial-gradient(120% 80% at 20% 0%, rgba(120, 170, 255, 0.35), transparent 55%),
48
+ linear-gradient(180deg, #2a3142 0%, #1a1e28 100%);
49
+ }
50
+ .app {
51
+ display: flex;
52
+ flex-direction: column;
53
+ align-items: center;
54
+ gap: 8px;
55
+ width: 72px;
56
+ }
57
+ .icon {
58
+ width: 56px;
59
+ height: 56px;
60
+ border-radius: 13px;
61
+ object-fit: cover;
62
+ background: #e8ebf0;
63
+ color: #5b6475;
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ font-size: 22px;
68
+ font-weight: 600;
69
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
70
+ }
71
+ .label {
72
+ width: 72px;
73
+ color: #fff;
74
+ font-size: 11px;
75
+ line-height: 14px;
76
+ text-align: center;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
79
+ white-space: nowrap;
80
+ }
81
+ label { display: block; margin: 0 0 14px; }
82
+ label span { display: block; margin-bottom: 4px; }
83
+ input, .drop {
84
+ width: 100%;
85
+ border: 1px solid var(--line);
86
+ border-radius: 10px;
87
+ background: #fff;
88
+ padding: 8px 10px;
89
+ font: inherit;
90
+ }
91
+ .drop {
92
+ min-height: 72px;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ color: var(--muted);
97
+ border-style: dashed;
98
+ cursor: pointer;
99
+ }
100
+ button {
101
+ appearance: none;
102
+ border: 0;
103
+ border-radius: 10px;
104
+ background: #1456f0;
105
+ color: #fff;
106
+ padding: 8px 14px;
107
+ font: inherit;
108
+ cursor: pointer;
109
+ }
110
+ button:disabled { opacity: 0.55; cursor: default; }
111
+ .note, .err { color: var(--muted); font-size: 12px; line-height: 18px; }
112
+ .err { color: #c62828; }
113
+ .path { margin-top: 16px; word-break: break-all; }
114
+ @media (max-width: 720px) {
115
+ main { grid-template-columns: 1fr; }
116
+ }
117
+ </style>
118
+ </head>
119
+ <body>
120
+ <main>
121
+ <div class="phone" aria-hidden="true">
122
+ <div class="screen">
123
+ <div class="app">
124
+ <div id="icon" class="icon">应</div>
125
+ <div id="preview" class="label">应用名称</div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ <section>
130
+ <h1>桌面外观</h1>
131
+ <p class="lead">写进当前 RN 工程。控制台是独立部署的 SaaS,不管图标和包名。</p>
132
+ <label>
133
+ <span>图标</span>
134
+ <div class="drop" id="drop">拖入或点击上传 PNG</div>
135
+ <input id="file" type="file" accept="image/png" hidden />
136
+ </label>
137
+ <label>
138
+ <span>名称</span>
139
+ <input id="name" placeholder="论玄" />
140
+ </label>
141
+ <label>
142
+ <span>iOS Bundle ID</span>
143
+ <input id="ios" placeholder="com.lunxuan.app" />
144
+ </label>
145
+ <label>
146
+ <span>Android 包名</span>
147
+ <input id="android" placeholder="com.lunxuan.app" />
148
+ </label>
149
+ <button id="save" type="button">写进工程</button>
150
+ <p class="note">会改 Info.plist、project.pbxproj、strings.xml、applicationId、app.json,以及 AppIcon / mipmap。Android 的 Java/Kotlin 目录名不会动。</p>
151
+ <p class="path note" id="root"></p>
152
+ <p class="err" id="err"></p>
153
+ </section>
154
+ </main>
155
+ <script>
156
+ const nameEl = document.getElementById('name');
157
+ const iosEl = document.getElementById('ios');
158
+ const androidEl = document.getElementById('android');
159
+ const preview = document.getElementById('preview');
160
+ const iconEl = document.getElementById('icon');
161
+ const drop = document.getElementById('drop');
162
+ const file = document.getElementById('file');
163
+ const save = document.getElementById('save');
164
+ const err = document.getElementById('err');
165
+ const rootEl = document.getElementById('root');
166
+ let iconBase64 = '';
167
+
168
+ function showName() {
169
+ const value = nameEl.value.trim() || '应用名称';
170
+ preview.textContent = value;
171
+ if (!iconEl.querySelector('img')) {
172
+ iconEl.textContent = value.slice(0, 1);
173
+ }
174
+ }
175
+
176
+ function setIcon(src) {
177
+ iconEl.innerHTML = '';
178
+ const img = document.createElement('img');
179
+ img.className = 'icon';
180
+ img.src = src;
181
+ iconEl.appendChild(img);
182
+ }
183
+
184
+ async function load() {
185
+ const data = await fetch('/api/brand').then(r => r.json());
186
+ nameEl.value = data.name || '';
187
+ iosEl.value = data.iosBundleId || '';
188
+ androidEl.value = data.androidPackage || '';
189
+ rootEl.textContent = data.root || '';
190
+ showName();
191
+ if (data.hasIcon) {
192
+ setIcon('/api/icon?t=' + Date.now());
193
+ }
194
+ }
195
+
196
+ function takePng(next) {
197
+ if (!next || !next.type.includes('png')) {
198
+ err.textContent = '请上传 PNG 图标';
199
+ return;
200
+ }
201
+ const reader = new FileReader();
202
+ reader.onload = () => {
203
+ const result = String(reader.result || '');
204
+ iconBase64 = result.split(',')[1] || '';
205
+ setIcon(result);
206
+ drop.textContent = '已选 ' + next.name;
207
+ };
208
+ reader.readAsDataURL(next);
209
+ }
210
+
211
+ nameEl.addEventListener('input', showName);
212
+ drop.addEventListener('click', () => file.click());
213
+ drop.addEventListener('dragover', event => event.preventDefault());
214
+ drop.addEventListener('drop', event => {
215
+ event.preventDefault();
216
+ takePng(event.dataTransfer.files[0]);
217
+ });
218
+ file.addEventListener('change', () => takePng(file.files[0]));
219
+ save.addEventListener('click', async () => {
220
+ err.textContent = '';
221
+ save.disabled = true;
222
+ try {
223
+ const res = await fetch('/api/apply', {
224
+ method: 'POST',
225
+ headers: {'Content-Type': 'application/json'},
226
+ body: JSON.stringify({
227
+ name: nameEl.value,
228
+ iosBundleId: iosEl.value,
229
+ androidPackage: androidEl.value,
230
+ iconBase64,
231
+ }),
232
+ });
233
+ const data = await res.json();
234
+ if (!res.ok) {
235
+ throw new Error(data.error || '写入失败');
236
+ }
237
+ drop.textContent = data.hasIcon ? '图标已写入' : '拖入或点击上传 PNG';
238
+ } catch (error) {
239
+ err.textContent = error instanceof Error ? error.message : '写入失败';
240
+ } finally {
241
+ save.disabled = false;
242
+ }
243
+ });
244
+ load().catch(error => {
245
+ err.textContent = error instanceof Error ? error.message : '读不到工程';
246
+ });
247
+ </script>
248
+ </body>
249
+ </html>
package/src/apply.ts ADDED
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {writeIcons} from './icons';
4
+ import {
5
+ androidGradlePath,
6
+ androidStringsPath,
7
+ firstFile,
8
+ iosPbxprojPath,
9
+ iosPlistPath,
10
+ readBrand,
11
+ type BrandState,
12
+ } from './project';
13
+
14
+ export type BrandInput = {
15
+ name: string;
16
+ iosBundleId?: string;
17
+ androidPackage?: string;
18
+ icon?: Buffer;
19
+ };
20
+
21
+ function xmlText(value: string) {
22
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
23
+ }
24
+
25
+ function replacePlistString(text: string, key: string, value: string) {
26
+ const next = `<key>${key}</key>\n\t<string>${xmlText(value)}</string>`;
27
+ if (new RegExp(`<key>${key}</key>\\s*<string>[^<]*</string>`).test(text)) {
28
+ return text.replace(new RegExp(`<key>${key}</key>\\s*<string>[^<]*</string>`), next);
29
+ }
30
+ return text.replace(/<dict>/, `<dict>\n\t${next}`);
31
+ }
32
+
33
+ export async function applyBrand(root: string, input: BrandInput): Promise<BrandState> {
34
+ const name = String(input.name || '').trim();
35
+ if (!name) {
36
+ throw new Error('先填名称');
37
+ }
38
+ const iosBundleId = String(input.iosBundleId || '').trim();
39
+ const androidPackage = String(input.androidPackage || '').trim();
40
+
41
+ const plist = iosPlistPath(root);
42
+ if (plist) {
43
+ const text = replacePlistString(fs.readFileSync(plist, 'utf8'), 'CFBundleDisplayName', name);
44
+ fs.writeFileSync(plist, text);
45
+ }
46
+
47
+ const pbx = iosPbxprojPath(root);
48
+ if (pbx && iosBundleId) {
49
+ const text = fs
50
+ .readFileSync(pbx, 'utf8')
51
+ .replace(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = "${iosBundleId}";`);
52
+ fs.writeFileSync(pbx, text);
53
+ }
54
+
55
+ const strings = androidStringsPath(root);
56
+ if (strings) {
57
+ const text = fs
58
+ .readFileSync(strings, 'utf8')
59
+ .replace(/<string name="app_name">[^<]*<\/string>/, `<string name="app_name">${xmlText(name)}</string>`);
60
+ fs.writeFileSync(strings, text);
61
+ }
62
+
63
+ const gradle = androidGradlePath(root);
64
+ if (gradle && androidPackage) {
65
+ const text = fs
66
+ .readFileSync(gradle, 'utf8')
67
+ .replace(/applicationId\s+["'][^"']+["']/, `applicationId "${androidPackage}"`);
68
+ fs.writeFileSync(gradle, text);
69
+ }
70
+
71
+ const appJson = firstFile(root, ['app.json']);
72
+ if (appJson) {
73
+ const data = JSON.parse(fs.readFileSync(appJson, 'utf8')) as {displayName?: string; name?: string};
74
+ data.displayName = name;
75
+ fs.writeFileSync(appJson, `${JSON.stringify(data, null, 2)}\n`);
76
+ }
77
+
78
+ if (input.icon) {
79
+ await writeIcons(root, input.icon);
80
+ }
81
+
82
+ return readBrand(root);
83
+ }
84
+
85
+ export function insideRoot(root: string, file: string) {
86
+ const resolved = path.resolve(file);
87
+ const base = path.resolve(root);
88
+ return resolved === base || resolved.startsWith(`${base}${path.sep}`);
89
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,28 @@
1
+ import {exec} from 'node:child_process';
2
+ import path from 'node:path';
3
+ import {findProjectRoot} from './project';
4
+ import {createBrandServer} from './server';
5
+
6
+ function arg(name: string, fallback = '') {
7
+ const index = process.argv.indexOf(name);
8
+ if (index >= 0 && process.argv[index + 1]) {
9
+ return process.argv[index + 1];
10
+ }
11
+ return fallback;
12
+ }
13
+
14
+ const origin = process.env.INIT_CWD || process.cwd();
15
+ const start = arg('--dir') ? path.resolve(origin, arg('--dir')) : origin;
16
+ const root = findProjectRoot(start);
17
+ const port = Number(arg('--port', '3939')) || 3939;
18
+ const openBrowser = !process.argv.includes('--no-open');
19
+ const server = createBrandServer(root);
20
+
21
+ server.listen(port, '127.0.0.1', () => {
22
+ const url = `http://127.0.0.1:${port}`;
23
+ console.log(`工程 ${root}`);
24
+ console.log(`品牌 ${url}`);
25
+ if (openBrowser && process.platform === 'darwin') {
26
+ exec(`open ${url}`);
27
+ }
28
+ });
package/src/icons.ts ADDED
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import sharp from 'sharp';
4
+ import {androidMipmapDirs, iosAppIconDir} from './project';
5
+
6
+ const IOS_ICONS = [
7
+ {file: 'icon-20@2x.png', idiom: 'iphone', scale: '2x', size: '20x20', px: 40},
8
+ {file: 'icon-20@3x.png', idiom: 'iphone', scale: '3x', size: '20x20', px: 60},
9
+ {file: 'icon-29@2x.png', idiom: 'iphone', scale: '2x', size: '29x29', px: 58},
10
+ {file: 'icon-29@3x.png', idiom: 'iphone', scale: '3x', size: '29x29', px: 87},
11
+ {file: 'icon-40@2x.png', idiom: 'iphone', scale: '2x', size: '40x40', px: 80},
12
+ {file: 'icon-40@3x.png', idiom: 'iphone', scale: '3x', size: '40x40', px: 120},
13
+ {file: 'icon-60@2x.png', idiom: 'iphone', scale: '2x', size: '60x60', px: 120},
14
+ {file: 'icon-60@3x.png', idiom: 'iphone', scale: '3x', size: '60x60', px: 180},
15
+ {file: 'icon-1024.png', idiom: 'ios-marketing', scale: '1x', size: '1024x1024', px: 1024},
16
+ ];
17
+
18
+ const ANDROID_SIZES: Record<string, number> = {
19
+ 'mipmap-mdpi': 48,
20
+ 'mipmap-hdpi': 72,
21
+ 'mipmap-xhdpi': 96,
22
+ 'mipmap-xxhdpi': 144,
23
+ 'mipmap-xxxhdpi': 192,
24
+ };
25
+
26
+ export function assertPngIcon(buffer: Buffer) {
27
+ if (buffer.length < 24 || buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
28
+ throw new Error('请上传 PNG 图标');
29
+ }
30
+ if (buffer.length > 8 * 1024 * 1024) {
31
+ throw new Error('图标不能超过 8MB');
32
+ }
33
+ }
34
+
35
+ export async function writeIcons(root: string, sourcePng: Buffer) {
36
+ assertPngIcon(sourcePng);
37
+ const iosDir = iosAppIconDir(root);
38
+ if (iosDir) {
39
+ fs.mkdirSync(iosDir, {recursive: true});
40
+ for (const item of IOS_ICONS) {
41
+ await sharp(sourcePng).resize(item.px, item.px).png().toFile(path.join(iosDir, item.file));
42
+ }
43
+ fs.writeFileSync(
44
+ path.join(iosDir, 'Contents.json'),
45
+ `${JSON.stringify(
46
+ {
47
+ images: IOS_ICONS.map(item => ({
48
+ filename: item.file,
49
+ idiom: item.idiom,
50
+ scale: item.scale,
51
+ size: item.size,
52
+ })),
53
+ info: {author: 'leiao-brand', version: 1},
54
+ },
55
+ null,
56
+ 2,
57
+ )}\n`,
58
+ );
59
+ }
60
+
61
+ const dirs = androidMipmapDirs(root);
62
+ for (const dir of dirs) {
63
+ const px = ANDROID_SIZES[path.basename(dir)] || 192;
64
+ await sharp(sourcePng).resize(px, px).png().toFile(path.join(dir, 'ic_launcher.png'));
65
+ await sharp(sourcePng).resize(px, px).png().toFile(path.join(dir, 'ic_launcher_round.png'));
66
+ }
67
+ }
package/src/project.ts ADDED
@@ -0,0 +1,163 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export type BrandState = {
5
+ root: string;
6
+ name: string;
7
+ iosBundleId: string;
8
+ androidPackage: string;
9
+ iconPath: string;
10
+ };
11
+
12
+ export function findProjectRoot(start: string) {
13
+ let dir = path.resolve(start);
14
+ for (let i = 0; i < 10; i++) {
15
+ if (
16
+ fs.existsSync(path.join(dir, 'ios')) &&
17
+ fs.existsSync(path.join(dir, 'android')) &&
18
+ fs.existsSync(path.join(dir, 'package.json'))
19
+ ) {
20
+ return dir;
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) {
24
+ break;
25
+ }
26
+ dir = parent;
27
+ }
28
+ throw new Error('请在 React Native 工程根目录运行(要有 ios/ 和 android/)');
29
+ }
30
+
31
+ export function firstFile(root: string, parts: string[]) {
32
+ const full = path.join(root, ...parts);
33
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
34
+ return full;
35
+ }
36
+ return '';
37
+ }
38
+
39
+ export function walkFiles(root: string, match: (rel: string, name: string) => boolean) {
40
+ const found: string[] = [];
41
+ if (!fs.existsSync(root)) {
42
+ return found;
43
+ }
44
+ const stack = [root];
45
+ while (stack.length) {
46
+ const current = stack.pop()!;
47
+ for (const entry of fs.readdirSync(current, {withFileTypes: true})) {
48
+ const full = path.join(current, entry.name);
49
+ const rel = path.relative(root, full);
50
+ if (rel.split(path.sep).includes('node_modules') || rel.split(path.sep).includes('Pods')) {
51
+ continue;
52
+ }
53
+ if (entry.isDirectory()) {
54
+ stack.push(full);
55
+ continue;
56
+ }
57
+ if (match(rel, entry.name)) {
58
+ found.push(full);
59
+ }
60
+ }
61
+ }
62
+ return found;
63
+ }
64
+
65
+ function plistString(text: string, key: string) {
66
+ const match = text.match(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`));
67
+ return match?.[1] || '';
68
+ }
69
+
70
+ function firstCapture(text: string, pattern: RegExp) {
71
+ const match = text.match(pattern);
72
+ return match?.[1] || '';
73
+ }
74
+
75
+ export function iosPlistPath(root: string) {
76
+ return (
77
+ walkFiles(path.join(root, 'ios'), (rel, name) => name === 'Info.plist' && !rel.includes('Test')).sort(
78
+ (a, b) => a.length - b.length,
79
+ )[0] || ''
80
+ );
81
+ }
82
+
83
+ export function iosPbxprojPath(root: string) {
84
+ return walkFiles(path.join(root, 'ios'), (_rel, name) => name === 'project.pbxproj')[0] || '';
85
+ }
86
+
87
+ export function iosAppIconDir(root: string) {
88
+ return (
89
+ walkFiles(path.join(root, 'ios'), (_rel, name) => name === 'Contents.json').find(file =>
90
+ file.includes('AppIcon.appiconset'),
91
+ )?.replace(/\/Contents\.json$/, '') || ''
92
+ );
93
+ }
94
+
95
+ export function androidStringsPath(root: string) {
96
+ return firstFile(root, ['android', 'app', 'src', 'main', 'res', 'values', 'strings.xml']);
97
+ }
98
+
99
+ export function androidGradlePath(root: string) {
100
+ return firstFile(root, ['android', 'app', 'build.gradle']);
101
+ }
102
+
103
+ export function androidMipmapDirs(root: string) {
104
+ const res = path.join(root, 'android', 'app', 'src', 'main', 'res');
105
+ if (!fs.existsSync(res)) {
106
+ return [];
107
+ }
108
+ return fs
109
+ .readdirSync(res, {withFileTypes: true})
110
+ .filter(entry => entry.isDirectory() && entry.name.startsWith('mipmap-') && !entry.name.includes('anydpi'))
111
+ .map(entry => path.join(res, entry.name));
112
+ }
113
+
114
+ export function currentIconPath(root: string) {
115
+ const appIcon = iosAppIconDir(root);
116
+ const marketing = appIcon ? path.join(appIcon, 'icon-1024.png') : '';
117
+ if (marketing && fs.existsSync(marketing)) {
118
+ return marketing;
119
+ }
120
+ const xxx = path.join(root, 'android', 'app', 'src', 'main', 'res', 'mipmap-xxxhdpi', 'ic_launcher.png');
121
+ if (fs.existsSync(xxx)) {
122
+ return xxx;
123
+ }
124
+ for (const dir of androidMipmapDirs(root)) {
125
+ const png = path.join(dir, 'ic_launcher.png');
126
+ if (fs.existsSync(png)) {
127
+ return png;
128
+ }
129
+ }
130
+ if (appIcon) {
131
+ const png = walkFiles(appIcon, (_rel, name) => name.endsWith('.png'))[0];
132
+ if (png) {
133
+ return png;
134
+ }
135
+ }
136
+ return '';
137
+ }
138
+
139
+ export function readBrand(root: string): BrandState {
140
+ const plist = iosPlistPath(root);
141
+ const pbx = iosPbxprojPath(root);
142
+ const strings = androidStringsPath(root);
143
+ const gradle = androidGradlePath(root);
144
+ const appJson = firstFile(root, ['app.json']);
145
+ const name =
146
+ (plist && plistString(fs.readFileSync(plist, 'utf8'), 'CFBundleDisplayName')) ||
147
+ (strings && firstCapture(fs.readFileSync(strings, 'utf8'), /<string name="app_name">([^<]*)<\/string>/)) ||
148
+ (appJson && String((JSON.parse(fs.readFileSync(appJson, 'utf8')) as {displayName?: string}).displayName || '')) ||
149
+ '';
150
+ const iosBundleId = pbx
151
+ ? firstCapture(fs.readFileSync(pbx, 'utf8'), /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/).replace(/^"|"$/g, '')
152
+ : '';
153
+ const androidPackage = gradle
154
+ ? firstCapture(fs.readFileSync(gradle, 'utf8'), /applicationId\s+["']([^"']+)["']/)
155
+ : '';
156
+ return {
157
+ root,
158
+ name,
159
+ iosBundleId,
160
+ androidPackage,
161
+ iconPath: currentIconPath(root),
162
+ };
163
+ }
package/src/server.ts ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import path from 'node:path';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {applyBrand} from './apply';
6
+ import {readBrand} from './project';
7
+
8
+ const publicDir = fileURLToPath(new URL('../public', import.meta.url));
9
+
10
+ function send(res: http.ServerResponse, status: number, body: string | Buffer, type: string) {
11
+ res.writeHead(status, {'Content-Type': type, 'Cache-Control': 'no-store'});
12
+ res.end(body);
13
+ }
14
+
15
+ function readBody(req: http.IncomingMessage) {
16
+ return new Promise<Buffer>((resolve, reject) => {
17
+ const chunks: Buffer[] = [];
18
+ req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
19
+ req.on('end', () => resolve(Buffer.concat(chunks)));
20
+ req.on('error', reject);
21
+ });
22
+ }
23
+
24
+ export function createBrandServer(root: string) {
25
+ return http.createServer(async (req, res) => {
26
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
27
+ try {
28
+ if (req.method === 'GET' && url.pathname === '/api/brand') {
29
+ const brand = readBrand(root);
30
+ send(res, 200, JSON.stringify({...brand, hasIcon: Boolean(brand.iconPath)}), 'application/json; charset=utf-8');
31
+ return;
32
+ }
33
+ if (req.method === 'GET' && url.pathname === '/api/icon') {
34
+ const brand = readBrand(root);
35
+ if (!brand.iconPath || !fs.existsSync(brand.iconPath)) {
36
+ send(res, 404, 'no icon', 'text/plain');
37
+ return;
38
+ }
39
+ send(res, 200, fs.readFileSync(brand.iconPath), 'image/png');
40
+ return;
41
+ }
42
+ if (req.method === 'POST' && url.pathname === '/api/apply') {
43
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}') as {
44
+ name?: string;
45
+ iosBundleId?: string;
46
+ androidPackage?: string;
47
+ iconBase64?: string;
48
+ };
49
+ const icon = body.iconBase64 ? Buffer.from(body.iconBase64, 'base64') : undefined;
50
+ const next = await applyBrand(root, {
51
+ name: body.name || '',
52
+ iosBundleId: body.iosBundleId,
53
+ androidPackage: body.androidPackage,
54
+ icon,
55
+ });
56
+ send(res, 200, JSON.stringify({...next, hasIcon: Boolean(next.iconPath)}), 'application/json; charset=utf-8');
57
+ return;
58
+ }
59
+ if (req.method === 'GET') {
60
+ const file = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
61
+ const full = path.normalize(path.join(publicDir, file));
62
+ if (!full.startsWith(publicDir) || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
63
+ send(res, 404, 'not found', 'text/plain');
64
+ return;
65
+ }
66
+ const type = full.endsWith('.css')
67
+ ? 'text/css; charset=utf-8'
68
+ : full.endsWith('.js')
69
+ ? 'text/javascript; charset=utf-8'
70
+ : 'text/html; charset=utf-8';
71
+ send(res, 200, fs.readFileSync(full), type);
72
+ return;
73
+ }
74
+ send(res, 405, 'method', 'text/plain');
75
+ } catch (error) {
76
+ send(res, 400, JSON.stringify({error: error instanceof Error ? error.message : '失败'}), 'application/json; charset=utf-8');
77
+ }
78
+ });
79
+ }