shellbase 0.13.5 → 0.14.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 +36 -2
- package/dist/agent.js +265 -60
- package/dist/crypto.js +24 -4
- package/dist/media.js +139 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,11 +158,28 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
|
|
|
158
158
|
- 모나코에서 **JS/TS 타입 기반 자동완성은 빠져 있어요** (그 기능 파일 하나가 6.6MB 라 웹 스토리지 한도를
|
|
159
159
|
넘습니다). 색칠·단축키·검색은 모두 정상이에요.
|
|
160
160
|
|
|
161
|
-
- 512KB 까지, **글자 파일만** 열려요 (
|
|
161
|
+
- 512KB 까지, **글자 파일만** 열려요 (사진·동영상은 아래의 보기 창으로 열립니다)
|
|
162
162
|
- 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
|
|
163
163
|
- 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
|
|
164
164
|
- 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
|
|
165
165
|
|
|
166
|
+
### 사진·동영상 보기 (0.14.0+)
|
|
167
|
+
|
|
168
|
+
폴더 찾기에서 **사진(🖼)·동영상(🎬)·소리(🎵)·PDF(📕)** 를 탭하면 편집기 대신 **보기 창**이 열려서
|
|
169
|
+
폰에서 그대로 보입니다. 동영상은 재생·되감기까지 돼요.
|
|
170
|
+
|
|
171
|
+
- 열리는 형식: `png jpg gif webp avif bmp heic tif` · `mp4 mov m4v webm mkv avi` ·
|
|
172
|
+
`mp3 m4a aac wav ogg opus flac` · `pdf`
|
|
173
|
+
- **64MB 까지**, 그리고 **3MB 가 넘으면 "받을까요?" 를 한 번 물어봐요** — 파일이 터미널 화면과
|
|
174
|
+
**같은 통로**로 오기 때문에, 큰 동영상을 잘못 누르면 그동안 터미널이 굼떠집니다
|
|
175
|
+
- 받는 동안 진행 막대가 보이고 **그만두기**를 누르면 곧바로 멈춰요 (컴퓨터 쪽도 바로 손을 뗍니다)
|
|
176
|
+
- 속도는 **초당 300KB 남짓**이에요 (실측) — 3MB 사진이면 10초쯤, 10MB 동영상이면 35초쯤
|
|
177
|
+
- 사진은 눌러서 **원래 크기 ↔ 화면 맞추기**를 오갈 수 있어요
|
|
178
|
+
- 파일은 32KB 조각으로 나눠 **세션 키로 암호화**해서 오고, 폰에서만 다시 하나로 붙습니다 —
|
|
179
|
+
중간에 저장되는 곳은 없어요
|
|
180
|
+
- 브라우저가 못 여는 형식(아이폰 밖에서의 HEIC, mkv·avi 등)은 그렇게 알려주고, **경로를
|
|
181
|
+
터미널에 넣어주는 버튼**을 보여줘요 — 그 자리에서 `ffmpeg` 로 바꾸면 됩니다
|
|
182
|
+
|
|
166
183
|
### 폰에서 사진·스크린샷 첨부 (0.7.0+)
|
|
167
184
|
|
|
168
185
|
폰 화면의 **클립(📎) 버튼**(PC 는 상단 바)으로 사진을 고르면, 사진이 **이 컴퓨터의
|
|
@@ -173,7 +190,7 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
|
|
|
173
190
|
- **PC 는 스크린샷 파일을 터미널 위로 끌어다 놓으면(드래그&드롭)** 바로 첨부돼요 (📎 버튼은 폰 전용)
|
|
174
191
|
- 스크린샷을 **복사해서 붙여넣기**(Ctrl+V) 해도 첨부돼요 (PC·폰 공통)
|
|
175
192
|
- 사진은 보내기 전에 폰에서 **긴 변 1568px 로 줄여서** 올라가요(전송이 빠르고 인식 정확도는 그대로)
|
|
176
|
-
- 저장된 사진은
|
|
193
|
+
- 저장된 사진은 **하루가 지나면 자동으로 지워져요**, 파일 권한은 본인만 읽기(600)
|
|
177
194
|
- 사진도 터미널 화면과 똑같이 **세션 키로 암호화**해서 전송돼요
|
|
178
195
|
|
|
179
196
|
### 폰에서 마이크로 말하기 (음성 → 글자)
|
|
@@ -207,6 +224,23 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
|
|
|
207
224
|
> 끄는 건 프로세스가 계속 살아있으니 그대로 꺼져요. 컨테이너를 완전히 내리려면 컴퓨터에서
|
|
208
225
|
> `docker stop shellbase-agent` 를 쓰세요.
|
|
209
226
|
|
|
227
|
+
### 터미널은 셸 시작 파일(rc)을 읽지 않아요
|
|
228
|
+
|
|
229
|
+
폰에서 여는 터미널은 `~/.bashrc`(zsh 는 `~/.zshrc`) 같은 **시작 파일을 읽지 않고** 맨 셸로 뜹니다
|
|
230
|
+
(`bash --norc --noprofile`, `zsh --no-rcs`, `fish --no-config`, PowerShell `-NoProfile`).
|
|
231
|
+
|
|
232
|
+
시작 파일은 그 컴퓨터 앞에 앉은 사람이 자기 편하려고 꾸며둔 것이라, 그 컴퓨터에만 있는 명령을
|
|
233
|
+
가리키는 별칭이 흔합니다. 그게 딸려오면 다른 환경에서 명령이 엉뚱하게 깨져요
|
|
234
|
+
(`bash: gnuls: command not found` 같은 증상).
|
|
235
|
+
|
|
236
|
+
- 대신 **에이전트를 켤 때의 환경변수(PATH 포함)를 그대로 물려받습니다.** 그래서 `shellbase` 를
|
|
237
|
+
실행할 수 있던 셸에서 켰다면 `claude` 같은 명령은 그대로 잘 찾습니다.
|
|
238
|
+
- 시작 파일에서만 붙던 것(conda 초기화, 개인 별칭, 특정 도구 경로)은 안 따라옵니다.
|
|
239
|
+
필요하면 그 줄을 **에이전트를 켜는 쪽**에 넣거나, 세션 안에서 그때그때 불러 쓰세요
|
|
240
|
+
(예: `source ~/miniconda3/etc/profile.d/conda.sh`).
|
|
241
|
+
- 예전처럼 시작 파일을 읽게 하려면 `SHELLBASE_SHELL_RC=1 shellbase start …` 로 켜세요.
|
|
242
|
+
- 프롬프트는 꾸밈 없는 기본값(`bash-5.2$`)이 됩니다.
|
|
243
|
+
|
|
210
244
|
## 참고
|
|
211
245
|
|
|
212
246
|
- `node-pty` 를 사용해서 설치 시 네이티브 모듈을 컴파일합니다. macOS는 Xcode Command Line Tools,
|
package/dist/agent.js
CHANGED
|
@@ -11,12 +11,13 @@ const { Terminal: HeadlessTerminal } = requireCjs('@xterm/headless');
|
|
|
11
11
|
import { requireClient } from './auth.js';
|
|
12
12
|
import { TERMINAL_CATEGORY, CONTROL_TOKEN, SESSION_ENV } from './config.js';
|
|
13
13
|
import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
|
|
14
|
-
import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
|
|
14
|
+
import { encryptFrame, encryptBytes, decryptFrame, generateFrameKey, compress } from './crypto.js';
|
|
15
15
|
import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineNames, renameDevice, markSessionDone, keepDevice, } from './devices.js';
|
|
16
16
|
import { promptApproval } from './prompt.js';
|
|
17
17
|
import { touchRecentDir } from './recent-dirs.js';
|
|
18
18
|
import { listDirs, createEntry, deleteEntry, renameEntry } from './browse.js';
|
|
19
19
|
import { readTextFile, writeTextFile, chunkContent } from './files.js';
|
|
20
|
+
import { MediaReader, statMedia } from './media.js';
|
|
20
21
|
import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
|
|
21
22
|
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
22
23
|
import { pushSessionDone } from './push.js';
|
|
@@ -39,19 +40,40 @@ const RESTORE_SCROLLBACK_LINES = 300;
|
|
|
39
40
|
// (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
|
|
40
41
|
// 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
|
|
41
42
|
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
43
|
+
// 조각은 적을수록 좋다. 조각이 둘 이상이면 "화면 지우기" 만 먼저 도착하고 내용이 뒤따라오는
|
|
44
|
+
// 사이가 그대로 검은 화면으로 보이고, 유실될 확률도 조각 수만큼 늘어난다.
|
|
45
|
+
//
|
|
46
|
+
// 기준은 **실제로 통로에 실리는 크기**다. 예전에는 원본을 정해진 바이트 수(40KB)로 자르고
|
|
47
|
+
// "암호화하면 55KB쯤 되겠지" 하고 짐작했는데, 압축이 들어오면 그 짐작이 아예 성립하지 않는다
|
|
48
|
+
// (얼마나 줄어들지 미리 알 수 없다). 짐작이 크면 한도를 넘어 거부되고(=검은 화면), 작으면
|
|
49
|
+
// 쓸데없이 잘게 쪼개진다(=느려짐). 그래서 아래 packOutput 이 압축·암호화까지 실제로 해보고
|
|
50
|
+
// 이 값과 재본다 — 짐작할 일이 없어지고, 압축이 잘 되는 화면은 통째로 한 조각에 담긴다.
|
|
51
|
+
// 64KB 한도에서 JSON 겉포장(kind/to/seq 등) 몫을 빼고 여유를 둔 값.
|
|
52
|
+
const WIRE_BUDGET_BYTES = 56 * 1024;
|
|
49
53
|
// 서버가 재는 기준이 예상과 다를 수도 있으니, "너무 크다" 고 거부당하면 스스로 절반씩 줄인다.
|
|
50
54
|
// 이 아래로는 줄이지 않는다 (더 줄여도 안 되면 크기 문제가 아니다).
|
|
51
|
-
const
|
|
55
|
+
const MIN_WIRE_BUDGET_BYTES = 8 * 1024;
|
|
56
|
+
// 이보다 작은 조각은 압축하지 않는다. gzip 은 머리말만 20바이트쯤 되므로 타자 한 글자가
|
|
57
|
+
// 되비치는 것(수십 바이트)까지 압축하면 오히려 커진다. 큰 화면에서만 이득을 챙기면 된다.
|
|
58
|
+
const GZIP_MIN_BYTES = 512;
|
|
59
|
+
// 조각이 한도를 넘을 때 반으로 잘라 다시 재보는 횟수 상한 (2^12 = 4096 조각이면 어떤 경우도 넘친다).
|
|
60
|
+
// 상한이 없으면 이상한 입력에서 영영 쪼개고 있을 수 있다.
|
|
61
|
+
const MAX_PACK_DEPTH = 12;
|
|
52
62
|
// 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
|
|
53
63
|
// 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
|
|
54
64
|
const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
65
|
+
// 사진·동영상을 보낼 때 서버 답을 안 기다리고 잇달아 부치는 조각 수.
|
|
66
|
+
// 하나씩 답을 기다리면 조각마다 왕복 한 번(실측 276ms)이 그대로 지연이 되어 3MB 사진에 몇 분이
|
|
67
|
+
// 걸린다. 그렇다고 통째로 부치면 터미널 화면이 그 뒤로 밀린다 — 통로를 세션들이 나눠 쓰기 때문.
|
|
68
|
+
//
|
|
69
|
+
// **8이면 충분하다는 것은 실측으로 확인했다** (3MB 사진, 2026-08-21):
|
|
70
|
+
// 16·32 로 넓혀도 빨라지지 않았다 (229 → 173 → 183KB/s). 병목이 "답을 기다리는 시간" 이 아니라
|
|
71
|
+
// **통로의 굵기**라는 뜻이다. 그러니 더 넓혀봐야 터미널만 뒤로 밀린다.
|
|
72
|
+
// 올리기만 하는 속도는 394KB/s — 폰은 받는 길이 따로라 실제로는 이쪽에 가깝다.
|
|
73
|
+
const MEDIA_WINDOW = 8;
|
|
74
|
+
// 조각 하나가 실패했을 때 다시 부쳐보는 횟수. 이진 파일은 한 조각만 빠져도 아예 안 열리므로
|
|
75
|
+
// 글자 파일보다 끈질기게 다시 보낸다.
|
|
76
|
+
const MEDIA_RETRIES = 3;
|
|
55
77
|
// 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
|
|
56
78
|
// 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
|
|
57
79
|
// 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
|
|
@@ -75,12 +97,29 @@ const PENALTY_INTERVAL_MS = 250;
|
|
|
75
97
|
//
|
|
76
98
|
// 그래서 고정값을 버리고 **실제 왕복 시간을 재서 스스로 조절**한다. 잘 나가면 조금씩 좁히고,
|
|
77
99
|
// 실패하면 크게 벌린다(AIMD). 세션이 하나면 예전만큼 빠르고, 여러 개여도 스스로 안전한 값을 찾는다.
|
|
78
|
-
|
|
100
|
+
//
|
|
101
|
+
// 그런데 "간격 하나"로만 조절하는 것에도 한계가 있었다. 12ms 는 **초당 83건** 인데 서버 한도가
|
|
102
|
+
// 초당 100건이라, 세션 하나가 띄엄띄엄 보낼 때는 남아돌지만 세션 셋이 쉬지 않고 보내면 그 83건이
|
|
103
|
+
// 그대로 꽉 찬 상태로 계속된다 — 한도 코앞에 붙어서 달리는 셈이라 조금만 흔들려도 거부당했다.
|
|
104
|
+
// 반대로 타자를 칠 때는 12ms 조차 아깝다(글자 하나가 나가는 데 그만큼 밀린다).
|
|
105
|
+
//
|
|
106
|
+
// 그래서 "**몰아 보내기 여유 + 꾸준한 속도**" 두 가지로 나눠 잡는다(토큰 버킷).
|
|
107
|
+
// · 조용하다가 갑자기 보낼 때는 여유분만큼 **간격 없이** 곧바로 나간다 (타자 체감이 좋아진다)
|
|
108
|
+
// · 그 여유를 다 쓰면 그때부터 꾸준한 속도로 묶인다 (한도에 붙어 달리지 않는다)
|
|
109
|
+
// 즉 예전보다 순간적으로는 더 빠르고, 오래 이어질 때는 더 안전하다.
|
|
110
|
+
const CHANNEL_GAP_MIN_MS = 20;
|
|
79
111
|
const CHANNEL_GAP_MAX_MS = 200;
|
|
80
|
-
const CHANNEL_GAP_START_MS =
|
|
81
|
-
//
|
|
112
|
+
const CHANNEL_GAP_START_MS = 20;
|
|
113
|
+
// 규정 속도보다 이만큼 앞질러 나가는 것은 봐준다 = 몰아 보낼 수 있는 여유.
|
|
114
|
+
// 400ms 면 최소 간격 기준으로 20조각쯤을 간격 없이 연달아 부칠 수 있다. 조용했던 만큼만 쌓인다.
|
|
115
|
+
const CHANNEL_BURST_MS = 400;
|
|
116
|
+
// 실패했을 때 간격을 몇 배로 벌릴지
|
|
82
117
|
const CHANNEL_GAP_GROW = 2;
|
|
83
|
-
|
|
118
|
+
// 좁히는 것은 **시간**으로 한다. 예전에는 성공 한 번에 1ms 씩 좁혔는데, 200ms 까지 벌어지면
|
|
119
|
+
// 되돌아오는 데 성공 188번이 필요했다 — 그 뒤로 뜸해진 세션은 한참을 느린 채로 남았다.
|
|
120
|
+
// 이제는 마지막 실패로부터 이만큼 지날 때마다 한 단계씩 좁힌다 (조용해도 저절로 회복한다).
|
|
121
|
+
const CHANNEL_RECOVER_MS = 5_000;
|
|
122
|
+
const CHANNEL_GAP_SHRINK = 0.7;
|
|
84
123
|
// 슬롯 예약이 이만큼 넘게 밀렸으면 = 통로가 막혔다는 뜻. 이때는 프레임을 **더 부치지 않고**
|
|
85
124
|
// 출력을 계속 모은다(coalescing). 터미널은 "최종 화면"만 맞으면 되므로 중간 프레임을 합쳐도
|
|
86
125
|
// 잃는 게 없다 — 오히려 막힌 통로에 프레임을 밀어 넣지 않아서 훨씬 빨리 따라잡는다.
|
|
@@ -114,23 +153,14 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
|
|
|
114
153
|
// 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
|
|
115
154
|
// 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
|
|
116
155
|
const BUSY_MIN_BYTES = 2048;
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
while (start < buf.length) {
|
|
126
|
-
let end = Math.min(buf.length, start + budget);
|
|
127
|
-
// UTF-8 에서 이어지는 바이트는 10xxxxxx 로 시작한다 — 거기서 끊으면 글자가 반쪽이 된다
|
|
128
|
-
while (end > start + 1 && end < buf.length && (buf[end] & 0xc0) === 0x80)
|
|
129
|
-
end--;
|
|
130
|
-
parts.push(buf.toString('utf8', start, end));
|
|
131
|
-
start = end;
|
|
132
|
-
}
|
|
133
|
-
return parts;
|
|
156
|
+
// 반으로 자를 자리. 이모지처럼 두 칸을 차지하는 글자의 뒷짝 바로 앞에서 끊으면 글자가 깨지므로
|
|
157
|
+
// 그때는 한 칸 옮긴다. (자바스크립트 글자열은 UTF-16 이라 0xDC00~0xDFFF 가 '뒷짝' 이다)
|
|
158
|
+
function splitPoint(text) {
|
|
159
|
+
let mid = text.length >> 1;
|
|
160
|
+
const code = text.charCodeAt(mid);
|
|
161
|
+
if (code >= 0xdc00 && code <= 0xdfff)
|
|
162
|
+
mid += 1;
|
|
163
|
+
return Math.min(Math.max(mid, 1), text.length - 1);
|
|
134
164
|
}
|
|
135
165
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
136
166
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
@@ -156,6 +186,40 @@ function resolveShell() {
|
|
|
156
186
|
return process.env.COMSPEC ?? 'powershell.exe';
|
|
157
187
|
return process.env.SHELL ?? '/bin/bash';
|
|
158
188
|
}
|
|
189
|
+
// 폰에서 여는 터미널은 셸의 '시작 파일'(bash 의 ~/.bashrc, zsh 의 ~/.zshrc 같은 것)을 읽지 않는다.
|
|
190
|
+
// 시작 파일은 그 컴퓨터의 사람이 앉아서 쓰려고 꾸며둔 것이라, 그 컴퓨터에만 있는 명령을 가리키는
|
|
191
|
+
// 별칭이 흔하다. 그게 그대로 딸려오면 엉뚱한 곳에서 명령이 깨진다 (`gnuls: command not found`
|
|
192
|
+
// 사건 — PLAN.md §8.12). 그래서 껍데기 셸만 띄우고, 실행에 필요한 경로·환경변수는 시작 파일이
|
|
193
|
+
// 아니라 **에이전트를 켤 때의 환경**을 그대로 물려주는 쪽으로 통일한다 (sessionEnv 참고).
|
|
194
|
+
// 예전처럼 시작 파일을 읽게 하려면 SHELLBASE_SHELL_RC=1 을 주고 에이전트를 다시 시작하면 된다.
|
|
195
|
+
function shellArgs(shell) {
|
|
196
|
+
if (process.env.SHELLBASE_SHELL_RC)
|
|
197
|
+
return [];
|
|
198
|
+
const name = path.basename(shell).toLowerCase();
|
|
199
|
+
// 로그인용 파일(~/.profile 등)은 애초에 로그인 셸일 때만 읽히지만, 혹시 몰라 같이 막아둔다
|
|
200
|
+
if (name.includes('bash'))
|
|
201
|
+
return ['--norc', '--noprofile'];
|
|
202
|
+
if (name.includes('zsh'))
|
|
203
|
+
return ['--no-rcs'];
|
|
204
|
+
if (name.includes('fish'))
|
|
205
|
+
return ['--no-config'];
|
|
206
|
+
if (name.includes('pwsh') || name.includes('powershell'))
|
|
207
|
+
return ['-NoProfile'];
|
|
208
|
+
// sh·dash·ksh 는 이런 옵션이 없고 $ENV / $BASH_ENV 에 적힌 파일을 읽는다 — 그건 sessionEnv 에서 지운다
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
// 이 터미널이 어느 세션인지 환경변수로 심어둔다. 이 안에서 실행되는 모든 것(Claude Code,
|
|
212
|
+
// 그리고 Claude Code 가 끝날 때 실행하는 `shellbase notify`)이 그대로 물려받으므로,
|
|
213
|
+
// 알림이 왔을 때 어느 세션인지 바로 알 수 있다 (notify.ts).
|
|
214
|
+
function sessionEnv(deviceId) {
|
|
215
|
+
const env = { ...process.env, [SESSION_ENV]: deviceId };
|
|
216
|
+
if (!process.env.SHELLBASE_SHELL_RC) {
|
|
217
|
+
// sh·dash·ksh 계열은 옵션 대신 이 변수에 적힌 파일을 시작하면서 읽는다 — 같이 비운다
|
|
218
|
+
delete env.ENV;
|
|
219
|
+
delete env.BASH_ENV;
|
|
220
|
+
}
|
|
221
|
+
return env;
|
|
222
|
+
}
|
|
159
223
|
function readControl(data, key) {
|
|
160
224
|
if (typeof data !== 'string')
|
|
161
225
|
return null;
|
|
@@ -243,28 +307,44 @@ export async function runAgent(options) {
|
|
|
243
307
|
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도를 넘고, 넘는 순간부터
|
|
244
308
|
// ack 이 안 돌아와 그 출력이 밀리고 화면이 멈춘다.
|
|
245
309
|
//
|
|
246
|
-
// 간격은 **고정하지 않는다**.
|
|
310
|
+
// 간격은 **고정하지 않는다**. 실패하면 두 배로 벌리고, 실패 없이 시간이 지나면 스스로 좁힌다.
|
|
247
311
|
// 고정값은 "세션 하나에서 잰 값"이라 세션이 늘어나는 순간 그대로 무너졌다 (§상수 주석 참고).
|
|
248
312
|
let channelGapMs = CHANNEL_GAP_START_MS;
|
|
313
|
+
let lastWidenAt = 0;
|
|
314
|
+
// 좁히기는 시간이 지나면 저절로 된다 — 프레임이 오가는 길목에서 확인한다.
|
|
315
|
+
// (예전에는 "성공 한 번에 1ms" 라, 뜸해진 세션은 영영 느린 채로 남았다)
|
|
249
316
|
const easeChannelGap = () => {
|
|
250
|
-
if (channelGapMs
|
|
251
|
-
|
|
252
|
-
|
|
317
|
+
if (channelGapMs <= CHANNEL_GAP_MIN_MS)
|
|
318
|
+
return;
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (now - lastWidenAt < CHANNEL_RECOVER_MS)
|
|
321
|
+
return;
|
|
322
|
+
lastWidenAt = now;
|
|
323
|
+
channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, Math.round(channelGapMs * CHANNEL_GAP_SHRINK));
|
|
324
|
+
console.error(`통로가 한동안 조용해서 전송 간격을 ${channelGapMs}ms 로 되돌렸어요`);
|
|
253
325
|
};
|
|
254
326
|
const widenChannelGap = (why) => {
|
|
327
|
+
lastWidenAt = Date.now();
|
|
255
328
|
if (channelGapMs >= CHANNEL_GAP_MAX_MS)
|
|
256
329
|
return;
|
|
257
330
|
channelGapMs = Math.min(CHANNEL_GAP_MAX_MS, Math.max(1, channelGapMs) * CHANNEL_GAP_GROW);
|
|
258
331
|
console.error(`통로가 막혀서 전송 간격을 ${channelGapMs}ms 로 벌렸어요 (${why})`);
|
|
259
332
|
};
|
|
333
|
+
// "이 시각 이후여야 규정 속도에 맞는다" 는 가상의 시계. 부칠 때마다 간격만큼 앞으로 간다.
|
|
334
|
+
// 조용히 있으면 이 시계가 현재보다 뒤처지는데, **그 뒤처진 만큼이 곧 몰아 보낼 여유**다.
|
|
260
335
|
let nextSlotAt = 0;
|
|
261
|
-
// 지금
|
|
262
|
-
// 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (
|
|
263
|
-
const slotLagMs = () => Math.max(0, nextSlotAt - Date.now());
|
|
336
|
+
// 지금 예약이 얼마나 미래로 밀려 있는지 (몰아 보내기 여유를 빼고 센다). 이게 크면 = 통로가
|
|
337
|
+
// 막혔다는 뜻이고, 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (3초씩 밀리던 직접 원인).
|
|
338
|
+
const slotLagMs = () => Math.max(0, nextSlotAt - CHANNEL_BURST_MS - Date.now());
|
|
264
339
|
const takeSendSlot = async () => {
|
|
265
340
|
const now = Date.now();
|
|
266
|
-
|
|
267
|
-
|
|
341
|
+
// 규정상 나갈 시각. 조용했으면 지금이고, 몰아 보내는 중이면 앞선 것들 뒤로 밀린다.
|
|
342
|
+
const due = Math.max(now, nextSlotAt);
|
|
343
|
+
nextSlotAt = due + channelGapMs;
|
|
344
|
+
// 규정보다 CHANNEL_BURST_MS 만큼 앞질러 나가는 것은 봐준다 — 조각 몇 개는 간격 없이 곧바로
|
|
345
|
+
// 나가고, 여유를 다 쓴 뒤에야 꾸준한 속도로 묶인다.
|
|
346
|
+
// (예약을 **부르는 즉시** 잡아두므로 부른 순서 = 나가는 순서는 그대로 지켜진다)
|
|
347
|
+
const at = Math.max(now, due - CHANNEL_BURST_MS);
|
|
268
348
|
if (at > now)
|
|
269
349
|
await new Promise((resolve) => setTimeout(resolve, at - now));
|
|
270
350
|
};
|
|
@@ -298,17 +378,42 @@ export async function runAgent(options) {
|
|
|
298
378
|
session.sendChain = next.then(() => undefined, () => undefined);
|
|
299
379
|
return next;
|
|
300
380
|
};
|
|
301
|
-
// 실제로 쓰는 조각
|
|
302
|
-
// 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
303
|
-
// 재시도 전에 반드시 크기를 줄여야 한다.
|
|
304
|
-
let
|
|
305
|
-
const
|
|
306
|
-
if (
|
|
381
|
+
// 실제로 쓰는 조각 크기 (통로에 실리는 크기 기준). 서버가 "너무 크다" 고 거부하면 절반으로
|
|
382
|
+
// 줄여서 스스로 맞춘다 — 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
383
|
+
// (= 검은 화면이 영영 안 풀린다), 재시도 전에 반드시 크기를 줄여야 한다.
|
|
384
|
+
let wireBudget = WIRE_BUDGET_BYTES;
|
|
385
|
+
const shrinkWireBudget = (why) => {
|
|
386
|
+
if (wireBudget <= MIN_WIRE_BUDGET_BYTES)
|
|
307
387
|
return false;
|
|
308
|
-
|
|
309
|
-
console.error(`조각 크기를 ${Math.round(
|
|
388
|
+
wireBudget = Math.max(MIN_WIRE_BUDGET_BYTES, Math.floor(wireBudget / 2));
|
|
389
|
+
console.error(`조각 크기를 ${Math.round(wireBudget / 1024)}KB 로 줄였어요 (${why})`);
|
|
310
390
|
return true;
|
|
311
391
|
};
|
|
392
|
+
// 보낼 글을 프레임 조각들로 만든다 — **압축·암호화까지 실제로 해보고 크기를 재서** 나눈다.
|
|
393
|
+
//
|
|
394
|
+
// 압축을 넣으면 "원본 몇 바이트로 자를까" 는 답이 없는 질문이 된다. 같은 40KB라도 색이 촘촘한
|
|
395
|
+
// 화면과 회전 표시만 도는 화면은 압축 후 크기가 열 배 넘게 차이 난다. 짐작해서 자르면 크게
|
|
396
|
+
// 잡을 땐 한도를 넘어 통째로 거부되고(폰은 새까만 화면), 작게 잡을 땐 한 조각이면 될 것을
|
|
397
|
+
// 다섯 조각으로 쪼갠다(조각 사이 틈 = 검은 화면, 유실 확률도 조각 수만큼).
|
|
398
|
+
//
|
|
399
|
+
// 그래서 짐작하지 않는다. 통째로 담아보고, 안 들어가면 반으로 잘라 다시 담아본다.
|
|
400
|
+
// 잘 압축되는 화면은 그대로 **한 조각**으로 끝난다 (실측: 25KB 출력 → 7KB, 약 4.8배).
|
|
401
|
+
const packOutput = (session, text) => {
|
|
402
|
+
const pack = (part, depth) => {
|
|
403
|
+
// 타자 한 글자가 되비치는 것까지 압축하면 머리말 때문에 오히려 커진다 — 큰 것만 압축한다
|
|
404
|
+
const useGz = session.gzipOk && Buffer.byteLength(part, 'utf8') >= GZIP_MIN_BYTES;
|
|
405
|
+
const data = useGz
|
|
406
|
+
? encryptBytes(compress(part), session.frameKey)
|
|
407
|
+
: encryptFrame(part, session.frameKey);
|
|
408
|
+
// 한 글자까지 줄여도 안 들어가면 더 쪼갤 수가 없다 — 그냥 보내고 결과에 맡긴다
|
|
409
|
+
if (data.length <= wireBudget || part.length <= 1 || depth >= MAX_PACK_DEPTH) {
|
|
410
|
+
return [useGz ? { data, gz: true } : { data }];
|
|
411
|
+
}
|
|
412
|
+
const cut = splitPoint(part);
|
|
413
|
+
return [...pack(part.slice(0, cut), depth + 1), ...pack(part.slice(cut), depth + 1)];
|
|
414
|
+
};
|
|
415
|
+
return text ? pack(text, 0) : [];
|
|
416
|
+
};
|
|
312
417
|
// 지금까지 나온 출력이 '보이지 않는 터미널' 에 다 반영되기를 기다린다.
|
|
313
418
|
// (xterm 은 받은 글자를 곧바로가 아니라 조금 뒤에 처리한다 — 실측 확인)
|
|
314
419
|
const drainScreen = (session) => new Promise((resolve) => {
|
|
@@ -341,7 +446,8 @@ export async function runAgent(options) {
|
|
|
341
446
|
const results = await Promise.allSettled(parts.map((part, index) => sendFrame({
|
|
342
447
|
kind: 'output',
|
|
343
448
|
to: session.deviceId,
|
|
344
|
-
data:
|
|
449
|
+
data: part.data,
|
|
450
|
+
...(part.gz ? { gz: true } : {}),
|
|
345
451
|
// 복원 조각임을 표시해서, 폰이 다 모은 뒤 한 번에 그리게 한다 (검은 화면 방지)
|
|
346
452
|
seq: index,
|
|
347
453
|
total: parts.length,
|
|
@@ -355,7 +461,7 @@ export async function runAgent(options) {
|
|
|
355
461
|
const message = String(reason?.message ?? reason);
|
|
356
462
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
357
463
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
358
|
-
|
|
464
|
+
shrinkWireBudget(message);
|
|
359
465
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
360
466
|
}
|
|
361
467
|
return sent;
|
|
@@ -384,15 +490,17 @@ export async function runAgent(options) {
|
|
|
384
490
|
// 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
|
|
385
491
|
await enqueueSend(session, async () => {
|
|
386
492
|
try {
|
|
387
|
-
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
|
|
388
|
-
|
|
493
|
+
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다).
|
|
494
|
+
// 압축이 되는 폰이면 대개 한 조각으로 끝난다 — 조각이 적을수록 빠르고 덜 유실된다.
|
|
495
|
+
const parts = packOutput(session, pending);
|
|
389
496
|
session.inFlight += parts.length;
|
|
390
497
|
// send() 를 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
|
|
391
498
|
// 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
|
|
392
499
|
const results = parts.map((part) => sendFrame({
|
|
393
500
|
kind: 'output',
|
|
394
501
|
to: session.deviceId,
|
|
395
|
-
data:
|
|
502
|
+
data: part.data,
|
|
503
|
+
...(part.gz ? { gz: true } : {}),
|
|
396
504
|
}));
|
|
397
505
|
void Promise.allSettled(results)
|
|
398
506
|
.then((settled) => {
|
|
@@ -405,7 +513,7 @@ export async function runAgent(options) {
|
|
|
405
513
|
const message = String(reason?.message ?? reason);
|
|
406
514
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
407
515
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
408
|
-
|
|
516
|
+
shrinkWireBudget(message);
|
|
409
517
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
410
518
|
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 맞춘다**.
|
|
411
519
|
// 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
|
|
@@ -498,7 +606,7 @@ export async function runAgent(options) {
|
|
|
498
606
|
}
|
|
499
607
|
// 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
|
|
500
608
|
const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
|
|
501
|
-
const parts =
|
|
609
|
+
const parts = packOutput(session, payload);
|
|
502
610
|
const sent = await enqueueSend(session, () => sendParts(session, parts));
|
|
503
611
|
if (sent < parts.length) {
|
|
504
612
|
// 화면을 지우라는 첫 조각만 도착하고 내용이 끊긴 상태 = 폰에는 **새까만 화면**만 남는다.
|
|
@@ -536,7 +644,7 @@ export async function runAgent(options) {
|
|
|
536
644
|
// 두 번 연달아 실패하면 이유가 뭐라고 적혀 있든 크기부터 의심한다 — 같은 크기로 계속
|
|
537
645
|
// 다시 보내봐야 같은 이유로 막히고, 그동안 폰은 계속 새까만 화면이다.
|
|
538
646
|
if (session.restoreFails >= 2)
|
|
539
|
-
|
|
647
|
+
shrinkWireBudget('화면 복원이 거듭 실패');
|
|
540
648
|
console.error(`화면 복원이 중간에 끊겼어요 ("${session.name}") — ${wait}ms 뒤 다시 보낼게요.`);
|
|
541
649
|
session.restoreRetry = setTimeout(() => {
|
|
542
650
|
session.restoreRetry = null;
|
|
@@ -674,15 +782,12 @@ export async function runAgent(options) {
|
|
|
674
782
|
name: explicitName ?? (temporary ? `${defaultName(cwd)} (임시)` : defaultName(cwd)),
|
|
675
783
|
cwd,
|
|
676
784
|
rowId: '',
|
|
677
|
-
pty: pty.spawn(shell,
|
|
785
|
+
pty: pty.spawn(shell, shellArgs(shell), {
|
|
678
786
|
name: 'xterm-256color',
|
|
679
787
|
cols: 80,
|
|
680
788
|
rows: 24,
|
|
681
789
|
cwd,
|
|
682
|
-
|
|
683
|
-
// 그리고 Claude Code 가 끝날 때 실행하는 `shellbase notify`)이 그대로 물려받으므로,
|
|
684
|
-
// 알림이 왔을 때 어느 세션인지 바로 알 수 있다 (notify.ts).
|
|
685
|
-
env: { ...process.env, [SESSION_ENV]: deviceId },
|
|
790
|
+
env: sessionEnv(deviceId),
|
|
686
791
|
}),
|
|
687
792
|
screen: new HeadlessTerminal({
|
|
688
793
|
cols: 80,
|
|
@@ -714,6 +819,9 @@ export async function runAgent(options) {
|
|
|
714
819
|
hookSeen: false,
|
|
715
820
|
temporary,
|
|
716
821
|
viewerSeenAt: Date.now(),
|
|
822
|
+
// 폰이 접속하면서 "압축 읽을 수 있다" 고 알려주기 전까지는 압축하지 않는다
|
|
823
|
+
gzipOk: false,
|
|
824
|
+
mediaId: null,
|
|
717
825
|
};
|
|
718
826
|
session.screen.loadAddon(session.serializer);
|
|
719
827
|
session.pty.onData((data) => {
|
|
@@ -880,6 +988,13 @@ export async function runAgent(options) {
|
|
|
880
988
|
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리·출력 게이트에 쓴다)
|
|
881
989
|
const wasUnwatched = !hasViewer(session);
|
|
882
990
|
session.viewerSeenAt = Date.now();
|
|
991
|
+
// 압축된 화면을 읽을 수 있는 폰인지. 접속할 때(connect_request)는 **그 폰이 말한 그대로** 맞춘다 —
|
|
992
|
+
// 옛 폰이 새로 붙으면 압축을 꺼야 하기 때문이다(못 읽는 폰에 보내면 화면이 통째로 깨진다).
|
|
993
|
+
// 그 밖의 프레임에서는 켜기만 한다(핑·크기변경에는 이 표시가 없을 수 있으므로 끄면 안 된다).
|
|
994
|
+
if (frame.kind === 'connect_request')
|
|
995
|
+
session.gzipOk = frame.gzOk === true;
|
|
996
|
+
else if (frame.gzOk)
|
|
997
|
+
session.gzipOk = true;
|
|
883
998
|
// 안 보는 동안에는 실시간 프레임을 만들지 않으므로(pty.onData 참고), 다시 보기 시작하면
|
|
884
999
|
// 그 사이 화면이 어떻게 바뀌었는지 알 길이 없다 — 지금 화면을 통째로 보내 맞춰준다.
|
|
885
1000
|
// connect_request 는 아래에서 어차피 복원하므로 여기서 두 번 하지 않는다.
|
|
@@ -1025,6 +1140,96 @@ export async function runAgent(options) {
|
|
|
1025
1140
|
console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
|
|
1026
1141
|
return;
|
|
1027
1142
|
}
|
|
1143
|
+
// 폰에서 사진·동영상·소리 파일 보기 — **두 걸음**으로 나눠서 준다.
|
|
1144
|
+
// ① { path } 만 오면 "무슨 파일이고 얼마나 큰지"(media_meta)만 알려준다
|
|
1145
|
+
// ② 폰이 { path, mid, start: true } 로 다시 물어야 그때 조각을 보낸다
|
|
1146
|
+
// 한 걸음으로 하면 동영상을 잘못 누른 순간 수십 MB 가 통로로 쏟아진다. 크기를 먼저 알려주면
|
|
1147
|
+
// 폰이 "40MB 예요, 받을까요?" 를 물어본 뒤에 시작할 수 있다.
|
|
1148
|
+
if (frame.kind === 'read_media') {
|
|
1149
|
+
const control = readControl(frame.data, session.frameKey);
|
|
1150
|
+
if (!control || typeof control.path !== 'string')
|
|
1151
|
+
return;
|
|
1152
|
+
const filePath = control.path;
|
|
1153
|
+
const mid = typeof control.mid === 'string' ? control.mid : '';
|
|
1154
|
+
const fail = (reason) => void send({
|
|
1155
|
+
kind: 'media_error',
|
|
1156
|
+
to: session.deviceId,
|
|
1157
|
+
data: encryptFrame(JSON.stringify({ path: filePath, mid, reason }), session.frameKey),
|
|
1158
|
+
});
|
|
1159
|
+
// 「그만두기」 — 보내던 것을 멈추기만 한다 (보내는 쪽이 조각마다 이 표를 확인한다)
|
|
1160
|
+
if (control.stop) {
|
|
1161
|
+
if (!mid || session.mediaId === mid)
|
|
1162
|
+
session.mediaId = null;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const info = statMedia(filePath);
|
|
1166
|
+
if (!info.ok) {
|
|
1167
|
+
fail(info.reason);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (!control.start) {
|
|
1171
|
+
void send({
|
|
1172
|
+
kind: 'media_meta',
|
|
1173
|
+
to: session.deviceId,
|
|
1174
|
+
data: encryptFrame(JSON.stringify({
|
|
1175
|
+
path: filePath,
|
|
1176
|
+
mid,
|
|
1177
|
+
mime: info.mime,
|
|
1178
|
+
kind: info.kind,
|
|
1179
|
+
size: info.size,
|
|
1180
|
+
total: info.total,
|
|
1181
|
+
}), session.frameKey),
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// 새 받기가 시작되면 앞서 보내던 것은 스스로 멈춘다 (표가 바뀌었으므로)
|
|
1186
|
+
session.mediaId = mid;
|
|
1187
|
+
void (async () => {
|
|
1188
|
+
let reader;
|
|
1189
|
+
try {
|
|
1190
|
+
reader = new MediaReader(info);
|
|
1191
|
+
}
|
|
1192
|
+
catch (err) {
|
|
1193
|
+
fail(`파일을 열지 못했어요: ${err.message}`);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
for (let from = 0; from < info.total; from += MEDIA_WINDOW) {
|
|
1198
|
+
if (session.mediaId !== mid || session.closing)
|
|
1199
|
+
return;
|
|
1200
|
+
const to = Math.min(from + MEDIA_WINDOW, info.total);
|
|
1201
|
+
let pending = [];
|
|
1202
|
+
for (let index = from; index < to; index++)
|
|
1203
|
+
pending.push(index);
|
|
1204
|
+
for (let attempt = 0; attempt < MEDIA_RETRIES && pending.length > 0; attempt++) {
|
|
1205
|
+
const results = await Promise.allSettled(pending.map((index) => sendFrame({
|
|
1206
|
+
kind: 'media_chunk',
|
|
1207
|
+
to: session.deviceId,
|
|
1208
|
+
mid,
|
|
1209
|
+
seq: index,
|
|
1210
|
+
total: info.total,
|
|
1211
|
+
data: encryptBytes(reader.chunk(index), session.frameKey),
|
|
1212
|
+
})));
|
|
1213
|
+
if (session.mediaId !== mid)
|
|
1214
|
+
return;
|
|
1215
|
+
pending = pending.filter((_, at) => results[at].status === 'rejected');
|
|
1216
|
+
}
|
|
1217
|
+
if (pending.length > 0) {
|
|
1218
|
+
fail('보내는 도중에 통로가 막혔어요. 잠시 뒤 다시 받아보세요.');
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const label = { image: '사진', video: '동영상', audio: '소리', pdf: 'PDF' }[info.kind];
|
|
1223
|
+
console.log(`📱 폰으로 ${label} 을 보냈어요: ${filePath}`);
|
|
1224
|
+
}
|
|
1225
|
+
finally {
|
|
1226
|
+
reader.close();
|
|
1227
|
+
if (session.mediaId === mid)
|
|
1228
|
+
session.mediaId = null;
|
|
1229
|
+
}
|
|
1230
|
+
})();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1028
1233
|
// 폰에서 마이크로 말한 녹음 — 조각이 다 모이면 이 컴퓨터의 음성인식 서버로 넘겨 글자로 돌려준다.
|
|
1029
1234
|
// 녹음은 인터넷의 다른 음성인식 서비스로 나가지 않고 이 컴퓨터 안에서만 처리된다.
|
|
1030
1235
|
if (frame.kind === 'stt_audio') {
|
package/dist/crypto.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import zlib from 'node:zlib';
|
|
2
3
|
const ALGO = 'aes-256-gcm';
|
|
3
4
|
const IV_LEN = 12;
|
|
4
5
|
const TAG_LEN = 16;
|
|
@@ -9,15 +10,15 @@ const TAG_LEN = 16;
|
|
|
9
10
|
export function generateFrameKey() {
|
|
10
11
|
return crypto.randomBytes(32).toString('base64');
|
|
11
12
|
}
|
|
12
|
-
export function
|
|
13
|
+
export function encryptBytes(plain, keyB64) {
|
|
13
14
|
const key = Buffer.from(keyB64, 'base64');
|
|
14
15
|
const iv = crypto.randomBytes(IV_LEN);
|
|
15
16
|
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
16
|
-
const ciphertext = Buffer.concat([cipher.update(
|
|
17
|
+
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
17
18
|
const authTag = cipher.getAuthTag();
|
|
18
19
|
return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
|
|
19
20
|
}
|
|
20
|
-
export function
|
|
21
|
+
export function decryptToBytes(payloadB64, keyB64) {
|
|
21
22
|
const key = Buffer.from(keyB64, 'base64');
|
|
22
23
|
const raw = Buffer.from(payloadB64, 'base64');
|
|
23
24
|
const iv = raw.subarray(0, IV_LEN);
|
|
@@ -25,5 +26,24 @@ export function decryptFrame(payloadB64, keyB64) {
|
|
|
25
26
|
const ciphertext = raw.subarray(IV_LEN, raw.length - TAG_LEN);
|
|
26
27
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
27
28
|
decipher.setAuthTag(authTag);
|
|
28
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
29
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
30
|
+
}
|
|
31
|
+
export function encryptFrame(plaintext, keyB64) {
|
|
32
|
+
return encryptBytes(Buffer.from(plaintext, 'utf8'), keyB64);
|
|
33
|
+
}
|
|
34
|
+
export function decryptFrame(payloadB64, keyB64) {
|
|
35
|
+
return decryptToBytes(payloadB64, keyB64).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
// ── 압축 ────────────────────────────────────────────────────────────────────
|
|
38
|
+
// 터미널 출력은 같은 것이 끝없이 반복된다(색 코드·회전 표시·화면 전체 다시 그리기). 실측으로
|
|
39
|
+
// 5배 안팎 줄어드는데, 이 통로의 병목은 굵기가 아니라 **초당 보낼 수 있는 건수**라서 크기가
|
|
40
|
+
// 줄면 조각 수가 그대로 줄어든다 — 조각 사이의 틈이 곧 검은 화면이었으므로 효과가 크다.
|
|
41
|
+
//
|
|
42
|
+
// 반드시 **압축 → 암호화** 순서다. 암호문은 무작위에 가까워서 뒤에 압축하면 오히려 커진다.
|
|
43
|
+
// 압축 비용은 실측 0.68ms/256KB (level 6) 로 사실상 없다.
|
|
44
|
+
export function compress(plaintext) {
|
|
45
|
+
return zlib.gzipSync(Buffer.from(plaintext, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
export function decompress(raw) {
|
|
48
|
+
return zlib.gunzipSync(raw).toString('utf8');
|
|
29
49
|
}
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// 폰에서 사진·동영상·소리 파일을 **보이게** 하는 부분.
|
|
5
|
+
// 글자 파일(files.ts)과 통로는 같지만 다루는 것이 다르다:
|
|
6
|
+
// · 글자는 조각을 이어 붙이면 그만이지만, 이진 파일은 한 바이트라도 틀리면 아예 안 열린다
|
|
7
|
+
// · 크기가 글자 파일과 자릿수가 다르다 (메모 몇 KB ↔ 동영상 수십 MB)
|
|
8
|
+
// 그래서 base64 를 한 겹 더 씌우지 않고 **바이트 그대로 암호화해서**(crypto.ts 의 encryptBytes)
|
|
9
|
+
// 보낸다 — 같은 프레임에 3분의 1을 더 실을 수 있다.
|
|
10
|
+
// 한 조각에 담는 원본 바이트 수. 암호화하면 base64 로 약 1.34배가 되므로
|
|
11
|
+
// 32KB → 약 43KB 로, 프레임 한도(56KB)에 여유를 두고 들어간다.
|
|
12
|
+
export const MEDIA_CHUNK_BYTES = 32 * 1024;
|
|
13
|
+
// 이보다 큰 파일은 아예 보내지 않는다. 실시간 통로는 터미널 화면과 **같이 쓰는 길**이라
|
|
14
|
+
// (세션 여럿이 채널 하나를 나눠 쓴다) 너무 큰 파일은 그동안 터미널을 굼뜨게 만든다.
|
|
15
|
+
export const MAX_MEDIA_SIZE = 64 * 1024 * 1024;
|
|
16
|
+
// 확장자로 어떤 종류인지 본다. 파일 안을 뜯어보지 않는 이유는, 폰 브라우저도 결국
|
|
17
|
+
// 여기서 정한 종류(mime)를 믿고 재생하기 때문이다 — 판단 기준을 한 곳에만 두는 편이 낫다.
|
|
18
|
+
const MIME_BY_EXT = {
|
|
19
|
+
// 사진
|
|
20
|
+
png: 'image/png',
|
|
21
|
+
jpg: 'image/jpeg',
|
|
22
|
+
jpeg: 'image/jpeg',
|
|
23
|
+
gif: 'image/gif',
|
|
24
|
+
webp: 'image/webp',
|
|
25
|
+
avif: 'image/avif',
|
|
26
|
+
bmp: 'image/bmp',
|
|
27
|
+
ico: 'image/x-icon',
|
|
28
|
+
heic: 'image/heic',
|
|
29
|
+
heif: 'image/heif',
|
|
30
|
+
tif: 'image/tiff',
|
|
31
|
+
tiff: 'image/tiff',
|
|
32
|
+
// 동영상
|
|
33
|
+
mp4: 'video/mp4',
|
|
34
|
+
m4v: 'video/mp4',
|
|
35
|
+
mov: 'video/quicktime',
|
|
36
|
+
webm: 'video/webm',
|
|
37
|
+
ogv: 'video/ogg',
|
|
38
|
+
mkv: 'video/x-matroska',
|
|
39
|
+
avi: 'video/x-msvideo',
|
|
40
|
+
// 소리
|
|
41
|
+
mp3: 'audio/mpeg',
|
|
42
|
+
m4a: 'audio/mp4',
|
|
43
|
+
aac: 'audio/aac',
|
|
44
|
+
wav: 'audio/wav',
|
|
45
|
+
ogg: 'audio/ogg',
|
|
46
|
+
oga: 'audio/ogg',
|
|
47
|
+
opus: 'audio/ogg',
|
|
48
|
+
flac: 'audio/flac',
|
|
49
|
+
// 문서 — 폰 브라우저가 그대로 띄워준다
|
|
50
|
+
pdf: 'application/pdf',
|
|
51
|
+
};
|
|
52
|
+
export function mediaMime(name) {
|
|
53
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
54
|
+
return MIME_BY_EXT[ext] ?? null;
|
|
55
|
+
}
|
|
56
|
+
export function mediaKind(name) {
|
|
57
|
+
const mime = mediaMime(name);
|
|
58
|
+
if (!mime)
|
|
59
|
+
return null;
|
|
60
|
+
if (mime.startsWith('image/'))
|
|
61
|
+
return 'image';
|
|
62
|
+
if (mime.startsWith('video/'))
|
|
63
|
+
return 'video';
|
|
64
|
+
if (mime.startsWith('audio/'))
|
|
65
|
+
return 'audio';
|
|
66
|
+
return 'pdf';
|
|
67
|
+
}
|
|
68
|
+
function expand(target) {
|
|
69
|
+
if (target.startsWith('~'))
|
|
70
|
+
return path.join(os.homedir(), target.slice(1));
|
|
71
|
+
return path.resolve(target);
|
|
72
|
+
}
|
|
73
|
+
// 보내기 전에 "이 파일이 뭔지, 몇 조각인지" 만 먼저 알아본다.
|
|
74
|
+
// 폰은 이 답을 보고 큰 파일이면 "받을까요?" 를 한 번 물어본 뒤에야 받기를 시작한다.
|
|
75
|
+
export function statMedia(target) {
|
|
76
|
+
const full = expand(target);
|
|
77
|
+
let stat;
|
|
78
|
+
try {
|
|
79
|
+
stat = fs.statSync(full);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { ok: false, reason: `파일을 찾을 수 없어요: ${full}` };
|
|
83
|
+
}
|
|
84
|
+
if (stat.isDirectory())
|
|
85
|
+
return { ok: false, reason: '폴더는 열 수 없어요.' };
|
|
86
|
+
const mime = mediaMime(full);
|
|
87
|
+
const kind = mediaKind(full);
|
|
88
|
+
if (!mime || !kind)
|
|
89
|
+
return { ok: false, reason: '폰에서 보여줄 수 있는 형식이 아니에요.' };
|
|
90
|
+
if (stat.size === 0)
|
|
91
|
+
return { ok: false, reason: '파일이 비어 있어요.' };
|
|
92
|
+
if (stat.size > MAX_MEDIA_SIZE) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: `파일이 너무 커요 (${Math.round(stat.size / 1024 / 1024)}MB) — ` +
|
|
96
|
+
`${MAX_MEDIA_SIZE / 1024 / 1024}MB 까지만 보낼 수 있어요.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
full,
|
|
102
|
+
mime,
|
|
103
|
+
kind,
|
|
104
|
+
size: stat.size,
|
|
105
|
+
total: Math.ceil(stat.size / MEDIA_CHUNK_BYTES),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// 파일을 열어두고 조각을 하나씩 꺼내 쓰는 손잡이. 통째로 메모리에 올리지 않는 이유는,
|
|
109
|
+
// 64MB 짜리를 여러 세션에서 동시에 열면 에이전트가 그만큼을 계속 들고 있게 되기 때문이다.
|
|
110
|
+
export class MediaReader {
|
|
111
|
+
info;
|
|
112
|
+
fd;
|
|
113
|
+
constructor(info, fd) {
|
|
114
|
+
this.info = info;
|
|
115
|
+
this.fd = fd ?? fs.openSync(info.full, 'r');
|
|
116
|
+
}
|
|
117
|
+
chunk(index) {
|
|
118
|
+
const offset = index * MEDIA_CHUNK_BYTES;
|
|
119
|
+
const length = Math.min(MEDIA_CHUNK_BYTES, this.info.size - offset);
|
|
120
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
121
|
+
let read = 0;
|
|
122
|
+
// 한 번에 다 안 읽히는 경우가 있어서(네트워크 드라이브 등) 다 찰 때까지 이어 읽는다
|
|
123
|
+
while (read < length) {
|
|
124
|
+
const got = fs.readSync(this.fd, buffer, read, length - read, offset + read);
|
|
125
|
+
if (got <= 0)
|
|
126
|
+
break;
|
|
127
|
+
read += got;
|
|
128
|
+
}
|
|
129
|
+
return read === length ? buffer : buffer.subarray(0, read);
|
|
130
|
+
}
|
|
131
|
+
close() {
|
|
132
|
+
try {
|
|
133
|
+
fs.closeSync(this.fd);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// 이미 닫혔으면 그만
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|