@zsukim/ctv-run 1.0.10 → 1.0.12

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 CHANGED
@@ -18,14 +18,16 @@
18
18
 
19
19
  ## 실행 모드 개요
20
20
 
21
- 모든 플랫폼은 두 가지 모드를 지원합니다.
21
+ 플랫폼은 두 가지 모드를 지원합니다.
22
22
 
23
- | 모드 | 옵션 | 설명 |
24
- |------|------|------|
25
- | **Live Mode** | (기본값) | TV가 로드할 URL을 TV에 주입. HMR 지원 |
26
- | **Static Mode** | `--target=dist` | 빌드 결과물을 TV에 직접 설치 |
23
+ | 모드 | 옵션 | 설명 |
24
+ |------|------|----------------------------------------|
25
+ | **Live Mode** | (기본값) | TV가 로드할 URL을 TV에 주입. HMR 지원 (Tizen 제외) |
26
+ | **Static Mode** | `--target=dist` | 빌드 결과물을 TV에 직접 설치 (Vizio 제외) |
27
27
 
28
28
  > **Vizio**는 구조상 항상 URL이 필요하므로 Live Mode만 지원합니다.
29
+ >
30
+ > **Tizen** Live Mode는 HMR을 지원하지 않습니다. Samsung Tizen TV의 구형 WebKit이 ES native modules(`type="module"`)를 지원하지 않아 `vite dev` 서버에 접속하면 검은 화면이 나타납니다. `vite preview --host`를 사용하세요.
29
31
 
30
32
  ---
31
33
 
package/dist/icon.png ADDED
Binary file
@@ -30,14 +30,30 @@ function runTizen() {
30
30
  console.error('\n❌ Tizen TV IP 주소가 필요합니다! (예: --ip=192.168.x.x)');
31
31
  return;
32
32
  }
33
- const tizenBin = getArg('tizenPath') || ((_b = config.tizen) === null || _b === void 0 ? void 0 : _b.tizenPath) || 'tizen';
34
- const sdbBin = getArg('sdbPath') || ((_c = config.tizen) === null || _c === void 0 ? void 0 : _c.sdbPath) || 'sdb';
33
+ const resolveBin = (name, fallbackPaths) => {
34
+ try {
35
+ (0, child_process_1.execSync)(`which ${name}`, { stdio: 'ignore' });
36
+ return name;
37
+ }
38
+ catch (_a) {
39
+ return fallbackPaths.find((p) => fs_extra_1.default.existsSync(p)) || name;
40
+ }
41
+ };
42
+ const tizenBin = getArg('tizenPath') ||
43
+ ((_b = config.tizen) === null || _b === void 0 ? void 0 : _b.tizenPath) ||
44
+ resolveBin('tizen', [
45
+ `${process.env.HOME}/tizen-studio/tools/ide/bin/tizen`,
46
+ ]);
47
+ const sdbBin = getArg('sdbPath') ||
48
+ ((_c = config.tizen) === null || _c === void 0 ? void 0 : _c.sdbPath) ||
49
+ resolveBin('sdb', [`${process.env.HOME}/tizen-studio/tools/sdb`]);
35
50
  const targetArg = getArg('target');
36
51
  const isLiveMode = !targetArg;
37
- const port = getArg('port') || ((_d = config.tizen) === null || _d === void 0 ? void 0 : _d.port) || ((_e = config.common) === null || _e === void 0 ? void 0 : _e.devServerPort) || 3000;
38
- const appUrl = getArg('url') ||
39
- ((_f = config.tizen) === null || _f === void 0 ? void 0 : _f.url) ||
40
- `http://${(0, ip_1.getLocalIp)()}:${port}`;
52
+ const port = getArg('port') ||
53
+ ((_d = config.tizen) === null || _d === void 0 ? void 0 : _d.port) ||
54
+ ((_e = config.common) === null || _e === void 0 ? void 0 : _e.devServerPort) ||
55
+ 3000;
56
+ const appUrl = getArg('url') || ((_f = config.tizen) === null || _f === void 0 ? void 0 : _f.url) || `http://${(0, ip_1.getLocalIp)()}:${port}`;
41
57
  const tempAppDir = path_1.default.resolve(process.cwd(), '.ctv-run-tizen');
42
58
  if (fs_extra_1.default.existsSync(tempAppDir))
43
59
  fs_extra_1.default.removeSync(tempAppDir);
@@ -46,16 +62,8 @@ function runTizen() {
46
62
  console.log(`🚀 [${isLiveMode ? 'Live Mode' : 'Static Mode'}] 준비 중...`);
47
63
  // 콘텐츠 준비 및 기존 config.xml 탐색
48
64
  if (isLiveMode) {
49
- // Live Mode: 루트나 public 폴더에서 config.xml이 있는지 찾아서 복사
50
- const searchPaths = [
51
- path_1.default.resolve(process.cwd(), 'config.xml'),
52
- path_1.default.resolve(process.cwd(), 'public', 'config.xml'),
53
- ];
54
- const foundPath = searchPaths.find((p) => fs_extra_1.default.existsSync(p));
55
- if (foundPath)
56
- fs_extra_1.default.copySync(foundPath, path_1.default.join(tempAppDir, 'config.xml'));
57
- const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="background:#000;"><script type="text/javascript">window.location.replace("${appUrl}");</script></body></html>`;
58
- fs_extra_1.default.writeFileSync(path_1.default.join(tempAppDir, 'index.html'), redirectHtml);
65
+ // Live Mode: index.html은 TV가 content src URL로 바로 접속하므로 불필요
66
+ // config.xml의 content src에 개발서버 URL을 직접 지정
59
67
  }
60
68
  else {
61
69
  // Static Mode: 빌드 타겟 폴더 복사
@@ -73,56 +81,81 @@ function runTizen() {
73
81
  ((_g = config.tizen) === null || _g === void 0 ? void 0 : _g.appId) ||
74
82
  'A123456789.CtvRunApp';
75
83
  const safeAppName = ((xmlInfo === null || xmlInfo === void 0 ? void 0 : xmlInfo.appName) || 'CtvRunApp').replace(/\s+/g, '');
76
- // config.xml 없으면 생성
77
- yield ensureTizenFiles(appId, tempAppDir, safeAppName);
84
+ // static mode는 dist의 config.xml 유지, live mode는 개발서버 URL로 새로 생성
85
+ yield ensureTizenFiles(appId, tempAppDir, safeAppName, !isLiveMode, isLiveMode ? appUrl : '');
78
86
  // Tizen 인증서 프로필 확인/생성/활성화
79
87
  console.log('\n🎫 Tizen 인증서 프로필 확인 중...');
80
- const getActive = () => {
88
+ const profilesXmlPath = path_1.default.join(process.env.HOME || '', 'tizen-studio-data/profile/profiles.xml');
89
+ const readProfilesXml = () => {
81
90
  try {
82
- const list = (0, child_process_1.execSync)(`${tizenBin} security-profiles list`, {
83
- encoding: 'utf8',
84
- });
85
- const match = list.match(/Active Profile\s*:\s*(\S+)/);
86
- return match ? match[1] : '';
91
+ if (!fs_extra_1.default.existsSync(profilesXmlPath))
92
+ return { active: '', profiles: [] };
93
+ const xml = fs_extra_1.default.readFileSync(profilesXmlPath, 'utf8');
94
+ const activeMatch = xml.match(/<profiles[^>]*active="([^"]+)"/);
95
+ const profileMatches = [...xml.matchAll(/<profile\s+name="([^"]+)"/g)];
96
+ return {
97
+ active: activeMatch ? activeMatch[1] : '',
98
+ profiles: profileMatches.map((m) => m[1]),
99
+ };
87
100
  }
88
101
  catch (e) {
89
- return '';
102
+ return { active: '', profiles: [] };
90
103
  }
91
104
  };
92
- let activeProfile = getActive();
105
+ const setActiveProfile = (name) => {
106
+ (0, child_process_1.execSync)(`${tizenBin} security-profiles set-active -n "${name}"`, {
107
+ stdio: 'inherit',
108
+ });
109
+ };
110
+ let activeProfile = readProfilesXml().active;
93
111
  if (!activeProfile) {
94
- console.log('⚠️ 활성화된 프로필이 없습니다. 앱 전용 인증서를 세팅합니다.');
95
112
  const askQuestion = (query) => {
96
113
  const buffer = Buffer.alloc(1024);
97
114
  process.stdout.write(query);
98
115
  const bytesRead = fs_extra_1.default.readSync(0, buffer, 0, 1024, null);
99
116
  return buffer.toString('utf8', 0, bytesRead).trim();
100
117
  };
101
- const certFileName = 'ctv-run-cert';
102
- const profileName = 'ctv-run-profile';
103
- const homeDir = process.env.HOME || '';
104
- const defaultCertDir = path_1.default.join(homeDir, 'tizen-studio-data.1/keystore/author');
105
- const p12Path = path_1.default.join(defaultCertDir, `${certFileName}.p12`);
106
- let p12Pwd = '';
107
- if (fs_extra_1.default.existsSync(p12Path)) {
108
- console.log(`✨ 기존 인증서 발견: ${p12Path}`);
109
- p12Pwd = askQuestion('🔑 인증서 비밀번호를 입력하세요: ');
110
- }
111
- else {
112
- p12Pwd = askQuestion('🔑 새로 만들 인증서 비밀번호(4자 이상): ');
113
- (0, child_process_1.execSync)(`${tizenBin} certificate -a "${certFileName}" -p "${p12Pwd}" -f "${certFileName}"`, { stdio: 'inherit' });
114
- }
115
- try {
116
- (0, child_process_1.execSync)(`${tizenBin} security-profiles remove -n "${profileName}"`, {
117
- stdio: 'ignore',
118
+ // 등록된 프로필 목록 확인
119
+ const existingProfiles = readProfilesXml().profiles;
120
+ if (existingProfiles.length > 0) {
121
+ console.log('⚠️ 활성화된 프로필이 없습니다. 등록된 프로필 목록:');
122
+ existingProfiles.forEach((p, i) => {
123
+ console.log(` ${i + 1}. ${p}`);
118
124
  });
125
+ const answer = askQuestion('🔑 사용할 프로필 번호를 입력하세요 (새로운 ctv-run-cert 생성): ');
126
+ const idx = parseInt(answer, 10) - 1;
127
+ if (!isNaN(idx) && idx >= 0 && idx < existingProfiles.length) {
128
+ const chosen = existingProfiles[idx];
129
+ setActiveProfile(chosen);
130
+ activeProfile = readProfilesXml().active;
131
+ }
132
+ }
133
+ if (!activeProfile) {
134
+ console.log('⚠️ 활성화된 프로필이 없습니다. 앱 전용 인증서를 세팅합니다.');
135
+ const certFileName = 'ctv-run-cert';
136
+ const profileName = 'ctv-run-profile';
137
+ const homeDir = process.env.HOME || '';
138
+ const defaultCertDir = path_1.default.join(homeDir, 'tizen-studio-data/keystore/author');
139
+ const p12Path = path_1.default.join(defaultCertDir, `${certFileName}.p12`);
140
+ let p12Pwd = '';
141
+ if (fs_extra_1.default.existsSync(p12Path)) {
142
+ console.log(`✨ 기존 인증서 발견: ${p12Path}`);
143
+ p12Pwd = askQuestion('🔑 인증서 비밀번호를 입력하세요: ');
144
+ }
145
+ else {
146
+ p12Pwd = askQuestion('🔑 새로 만들 인증서 비밀번호(4자 이상): ');
147
+ (0, child_process_1.execSync)(`${tizenBin} certificate -a "${certFileName}" -p "${p12Pwd}" -f "${certFileName}"`, { stdio: 'inherit' });
148
+ }
149
+ try {
150
+ (0, child_process_1.execSync)(`${tizenBin} security-profiles remove -n "${profileName}"`, {
151
+ stdio: 'ignore',
152
+ });
153
+ }
154
+ catch (e) { }
155
+ (0, child_process_1.execSync)(`${tizenBin} security-profiles add -n "${profileName}" -a "${p12Path}" -p "${p12Pwd}"`, { stdio: 'inherit' });
156
+ setActiveProfile(profileName);
157
+ activeProfile = readProfilesXml().active;
119
158
  }
120
- catch (e) { }
121
- (0, child_process_1.execSync)(`${tizenBin} security-profiles add -n "${profileName}" -a "${p12Path}" -p "${p12Pwd}"`, { stdio: 'inherit' });
122
- (0, child_process_1.execSync)(`${tizenBin} security-profiles set-active -n "${profileName}"`, {
123
- stdio: 'inherit',
124
- });
125
- activeProfile = getActive();
126
159
  }
127
160
  // 패키징
128
161
  console.log(`\n🔨 [${activeProfile}] 프로필로 패키징 중...`);
@@ -151,7 +184,7 @@ function runTizen() {
151
184
  return buffer.toString('utf8', 0, bytesRead).trim().toLowerCase();
152
185
  };
153
186
  const answer = askQuestion('❓ 기존 앱을 삭제하고 다시 설치할까요? (Y/n): ');
154
- if (answer === 'Y') {
187
+ if (answer === 'y') {
155
188
  console.log(`🗑️ 기존 앱 삭제 중: ${appId}`);
156
189
  (0, child_process_1.execSync)(`${tizenBin} uninstall -p ${appId}`, { stdio: 'inherit' });
157
190
  console.log('✅ 삭제 완료. 재설치 시도...');
@@ -198,10 +231,15 @@ function getInfoFromXml(dir) {
198
231
  }
199
232
  return null;
200
233
  }
201
- function ensureTizenFiles(appId, tempDir, appName) {
202
- return __awaiter(this, void 0, void 0, function* () {
234
+ function ensureTizenFiles(appId_1, tempDir_1, appName_1) {
235
+ return __awaiter(this, arguments, void 0, function* (appId, tempDir, appName, keepExistingConfig = false, liveUrl = '') {
203
236
  const configXmlPath = path_1.default.join(tempDir, 'config.xml');
204
- // 기존에 꼬여있을 수 있는 config.xml을 삭제하고 새로 만듭니다.
237
+ if (keepExistingConfig && fs_extra_1.default.existsSync(configXmlPath)) {
238
+ // static mode: dist의 config.xml을 그대로 유지
239
+ (0, platform_1.resolveIcon)(tempDir);
240
+ return;
241
+ }
242
+ // live mode: 기존 config.xml 삭제 후 새로 생성
205
243
  if (fs_extra_1.default.existsSync(configXmlPath)) {
206
244
  fs_extra_1.default.removeSync(configXmlPath);
207
245
  }
@@ -210,19 +248,23 @@ function ensureTizenFiles(appId, tempDir, appName) {
210
248
  const packageId = appId.split('.')[0] || 'TizenApp';
211
249
  const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
212
250
  <widget xmlns:tizen="http://tizen.org/ns/widgets" xmlns="http://www.w3.org/ns/widgets" id="http://yourdomain/tizen/${appName}" version="1.0.0" viewmodes="maximized">
213
- <tizen:application id="${appId}" package="${packageId}" required_version="2.3"/>
214
- <tizen:profile name="tv-samsung"/>
251
+ <tizen:application id="${appId}" package="${packageId}" required_version="2.1"/>
215
252
  <content src="index.html"/>
216
- <name>${appName}</name>
253
+ <feature name="http://tizen.org/feature/screen.size.normal.1080.1920"/>
217
254
  <icon src="icon.png"/>
255
+ <name>${appName}</name>
256
+ <tizen:profile name="tv-samsung"/>
218
257
  <access origin="*" subdomains="true"/>
219
- <tizen:allow-navigation>*</tizen:allow-navigation>
220
258
  <tizen:privilege name="http://tizen.org/privilege/internet"/>
221
259
  <tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
222
260
  <tizen:privilege name="http://tizen.org/privilege/tv.inputdevice" />
223
261
  <tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable" encryption="disable" install-location="auto" hwkey-event="enable"/>
224
262
  </widget>`;
225
263
  fs_extra_1.default.writeFileSync(configXmlPath, xmlContent, 'utf8');
264
+ if (liveUrl) {
265
+ const redirectHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Loading...</title><meta http-equiv="refresh" content="0; url=${liveUrl}"></head><body style="background:#000;"></body></html>`;
266
+ fs_extra_1.default.writeFileSync(path_1.default.join(tempDir, 'index.html'), redirectHtml);
267
+ }
226
268
  (0, platform_1.resolveIcon)(tempDir);
227
269
  });
228
270
  }
@@ -70,8 +70,6 @@ function runWebOS() {
70
70
  if (fs_extra_1.default.existsSync(tempAppDir))
71
71
  fs_extra_1.default.removeSync(tempAppDir);
72
72
  fs_extra_1.default.ensureDirSync(tempAppDir);
73
- // appinfo.json 생성
74
- yield ensureWebosAppInfo(appId, tempAppDir);
75
73
  // 콘텐츠 모드 설정
76
74
  if (targetArg) {
77
75
  const distPath = path_1.default.resolve(process.cwd(), targetArg);
@@ -99,14 +97,21 @@ function runWebOS() {
99
97
  `;
100
98
  fs_extra_1.default.writeFileSync(path_1.default.join(tempAppDir, 'index.html'), redirectHtml);
101
99
  }
100
+ // appinfo.json 생성 (dist에 없으면 기본값으로 생성)
101
+ yield ensureWebosAppInfo(appId, tempAppDir);
102
+ // 실제 appId를 appinfo.json에서 읽음 (dist에 있던 값 우선)
103
+ const appInfoPath = path_1.default.join(tempAppDir, 'appinfo.json');
104
+ const resolvedAppId = fs_extra_1.default.existsSync(appInfoPath)
105
+ ? fs_extra_1.default.readJsonSync(appInfoPath).id || appId
106
+ : appId;
102
107
  // 아이콘
103
108
  (0, platform_1.resolveIcon)(tempAppDir);
104
109
  // 패키징
105
- console.log(`🔨 Packaging [${appId}]...`);
110
+ console.log(`🔨 Packaging [${resolvedAppId}]...`);
106
111
  (0, child_process_1.execSync)(`ares-package ${tempAppDir} -o .`, { stdio: 'inherit' });
107
112
  const ipkFile = fs_extra_1.default
108
113
  .readdirSync(process.cwd())
109
- .find((f) => f.startsWith(appId) && f.endsWith('.ipk'));
114
+ .find((f) => f.startsWith(resolvedAppId) && f.endsWith('.ipk'));
110
115
  if (ipkFile) {
111
116
  // 설치
112
117
  console.log(`🚀 Installing to ${deviceName}...`);
@@ -115,7 +120,7 @@ function runWebOS() {
115
120
  });
116
121
  // 런치
117
122
  console.log(`▶️ Launching App...`);
118
- (0, child_process_1.execSync)(`ares-launch -d ${deviceName} ${appId}`, { stdio: 'inherit' });
123
+ (0, child_process_1.execSync)(`ares-launch -d ${deviceName} ${resolvedAppId}`, { stdio: 'inherit' });
119
124
  // 파일 정리
120
125
  fs_extra_1.default.removeSync(path_1.default.resolve(process.cwd(), ipkFile));
121
126
  fs_extra_1.default.removeSync(tempAppDir);
@@ -12,15 +12,26 @@ function makeArgParser(args) {
12
12
  }
13
13
  function resolveIcon(tempDir) {
14
14
  const targetIconPath = path_1.default.join(tempDir, 'icon.png');
15
- const sourceIconPath = path_1.default.resolve(process.cwd(), 'src', 'assets', 'icon.png');
15
+ const iconSearchPaths = [
16
+ path_1.default.resolve(process.cwd(), 'src', 'assets', 'icon.png'),
17
+ path_1.default.resolve(process.cwd(), 'public', 'icon.png'),
18
+ path_1.default.resolve(process.cwd(), 'icon.png'),
19
+ path_1.default.resolve(process.cwd(), 'assets', 'icon.png'),
20
+ ];
21
+ const sourceIconPath = iconSearchPaths.find((p) => fs_extra_1.default.existsSync(p));
16
22
  if (fs_extra_1.default.existsSync(targetIconPath)) {
17
23
  console.log('✅ 기존 프로젝트의 아이콘을 유지합니다: icon.png');
18
24
  return;
19
25
  }
20
- if (fs_extra_1.default.existsSync(sourceIconPath)) {
26
+ const fallbackIconPath = path_1.default.join(__dirname, '../icon.png');
27
+ if (sourceIconPath) {
21
28
  fs_extra_1.default.copySync(sourceIconPath, targetIconPath);
22
29
  console.log('🖼 기본 아이콘을 가져왔습니다.');
23
30
  }
31
+ else if (fs_extra_1.default.existsSync(fallbackIconPath)) {
32
+ fs_extra_1.default.copySync(fallbackIconPath, targetIconPath);
33
+ console.log('🖼 ctv-run 기본 아이콘을 사용합니다.');
34
+ }
24
35
  else {
25
36
  const dummyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64');
26
37
  fs_extra_1.default.writeFileSync(targetIconPath, dummyPng);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zsukim/ctv-run",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Smart TV deployment CLI for Vizio, LG webOS, Fire TV, and Samsung Tizen",
5
5
  "keywords": [
6
6
  "ctv",
@@ -26,9 +26,9 @@
26
26
  "node": ">=22"
27
27
  },
28
28
  "scripts": {
29
- "build": "tsc",
29
+ "build": "tsc && node -e \"require('fs-extra').copySync('src/assets/icon.png', 'dist/icon.png')\"",
30
30
  "watch": "tsc -w",
31
- "prepublishOnly": "tsc",
31
+ "prepublishOnly": "tsc && node -e \"require('fs-extra').copySync('src/assets/icon.png', 'dist/icon.png')\"",
32
32
  "release:patch": "npm version patch && git push && git push --tags",
33
33
  "release:minor": "npm version minor && git push && git push --tags",
34
34
  "release:major": "npm version major && git push && git push --tags"