shellbase 0.13.5 → 0.13.6
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/dist/agent.js +125 -55
- package/dist/crypto.js +24 -4
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -11,7 +11,7 @@ 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';
|
|
@@ -39,16 +39,25 @@ const RESTORE_SCROLLBACK_LINES = 300;
|
|
|
39
39
|
// (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
|
|
40
40
|
// 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
|
|
41
41
|
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
42
|
+
// 조각은 적을수록 좋다. 조각이 둘 이상이면 "화면 지우기" 만 먼저 도착하고 내용이 뒤따라오는
|
|
43
|
+
// 사이가 그대로 검은 화면으로 보이고, 유실될 확률도 조각 수만큼 늘어난다.
|
|
44
|
+
//
|
|
45
|
+
// 기준은 **실제로 통로에 실리는 크기**다. 예전에는 원본을 정해진 바이트 수(40KB)로 자르고
|
|
46
|
+
// "암호화하면 55KB쯤 되겠지" 하고 짐작했는데, 압축이 들어오면 그 짐작이 아예 성립하지 않는다
|
|
47
|
+
// (얼마나 줄어들지 미리 알 수 없다). 짐작이 크면 한도를 넘어 거부되고(=검은 화면), 작으면
|
|
48
|
+
// 쓸데없이 잘게 쪼개진다(=느려짐). 그래서 아래 packOutput 이 압축·암호화까지 실제로 해보고
|
|
49
|
+
// 이 값과 재본다 — 짐작할 일이 없어지고, 압축이 잘 되는 화면은 통째로 한 조각에 담긴다.
|
|
50
|
+
// 64KB 한도에서 JSON 겉포장(kind/to/seq 등) 몫을 빼고 여유를 둔 값.
|
|
51
|
+
const WIRE_BUDGET_BYTES = 56 * 1024;
|
|
49
52
|
// 서버가 재는 기준이 예상과 다를 수도 있으니, "너무 크다" 고 거부당하면 스스로 절반씩 줄인다.
|
|
50
53
|
// 이 아래로는 줄이지 않는다 (더 줄여도 안 되면 크기 문제가 아니다).
|
|
51
|
-
const
|
|
54
|
+
const MIN_WIRE_BUDGET_BYTES = 8 * 1024;
|
|
55
|
+
// 이보다 작은 조각은 압축하지 않는다. gzip 은 머리말만 20바이트쯤 되므로 타자 한 글자가
|
|
56
|
+
// 되비치는 것(수십 바이트)까지 압축하면 오히려 커진다. 큰 화면에서만 이득을 챙기면 된다.
|
|
57
|
+
const GZIP_MIN_BYTES = 512;
|
|
58
|
+
// 조각이 한도를 넘을 때 반으로 잘라 다시 재보는 횟수 상한 (2^12 = 4096 조각이면 어떤 경우도 넘친다).
|
|
59
|
+
// 상한이 없으면 이상한 입력에서 영영 쪼개고 있을 수 있다.
|
|
60
|
+
const MAX_PACK_DEPTH = 12;
|
|
52
61
|
// 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
|
|
53
62
|
// 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
|
|
54
63
|
const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
@@ -75,12 +84,29 @@ const PENALTY_INTERVAL_MS = 250;
|
|
|
75
84
|
//
|
|
76
85
|
// 그래서 고정값을 버리고 **실제 왕복 시간을 재서 스스로 조절**한다. 잘 나가면 조금씩 좁히고,
|
|
77
86
|
// 실패하면 크게 벌린다(AIMD). 세션이 하나면 예전만큼 빠르고, 여러 개여도 스스로 안전한 값을 찾는다.
|
|
78
|
-
|
|
87
|
+
//
|
|
88
|
+
// 그런데 "간격 하나"로만 조절하는 것에도 한계가 있었다. 12ms 는 **초당 83건** 인데 서버 한도가
|
|
89
|
+
// 초당 100건이라, 세션 하나가 띄엄띄엄 보낼 때는 남아돌지만 세션 셋이 쉬지 않고 보내면 그 83건이
|
|
90
|
+
// 그대로 꽉 찬 상태로 계속된다 — 한도 코앞에 붙어서 달리는 셈이라 조금만 흔들려도 거부당했다.
|
|
91
|
+
// 반대로 타자를 칠 때는 12ms 조차 아깝다(글자 하나가 나가는 데 그만큼 밀린다).
|
|
92
|
+
//
|
|
93
|
+
// 그래서 "**몰아 보내기 여유 + 꾸준한 속도**" 두 가지로 나눠 잡는다(토큰 버킷).
|
|
94
|
+
// · 조용하다가 갑자기 보낼 때는 여유분만큼 **간격 없이** 곧바로 나간다 (타자 체감이 좋아진다)
|
|
95
|
+
// · 그 여유를 다 쓰면 그때부터 꾸준한 속도로 묶인다 (한도에 붙어 달리지 않는다)
|
|
96
|
+
// 즉 예전보다 순간적으로는 더 빠르고, 오래 이어질 때는 더 안전하다.
|
|
97
|
+
const CHANNEL_GAP_MIN_MS = 20;
|
|
79
98
|
const CHANNEL_GAP_MAX_MS = 200;
|
|
80
|
-
const CHANNEL_GAP_START_MS =
|
|
81
|
-
//
|
|
99
|
+
const CHANNEL_GAP_START_MS = 20;
|
|
100
|
+
// 규정 속도보다 이만큼 앞질러 나가는 것은 봐준다 = 몰아 보낼 수 있는 여유.
|
|
101
|
+
// 400ms 면 최소 간격 기준으로 20조각쯤을 간격 없이 연달아 부칠 수 있다. 조용했던 만큼만 쌓인다.
|
|
102
|
+
const CHANNEL_BURST_MS = 400;
|
|
103
|
+
// 실패했을 때 간격을 몇 배로 벌릴지
|
|
82
104
|
const CHANNEL_GAP_GROW = 2;
|
|
83
|
-
|
|
105
|
+
// 좁히는 것은 **시간**으로 한다. 예전에는 성공 한 번에 1ms 씩 좁혔는데, 200ms 까지 벌어지면
|
|
106
|
+
// 되돌아오는 데 성공 188번이 필요했다 — 그 뒤로 뜸해진 세션은 한참을 느린 채로 남았다.
|
|
107
|
+
// 이제는 마지막 실패로부터 이만큼 지날 때마다 한 단계씩 좁힌다 (조용해도 저절로 회복한다).
|
|
108
|
+
const CHANNEL_RECOVER_MS = 5_000;
|
|
109
|
+
const CHANNEL_GAP_SHRINK = 0.7;
|
|
84
110
|
// 슬롯 예약이 이만큼 넘게 밀렸으면 = 통로가 막혔다는 뜻. 이때는 프레임을 **더 부치지 않고**
|
|
85
111
|
// 출력을 계속 모은다(coalescing). 터미널은 "최종 화면"만 맞으면 되므로 중간 프레임을 합쳐도
|
|
86
112
|
// 잃는 게 없다 — 오히려 막힌 통로에 프레임을 밀어 넣지 않아서 훨씬 빨리 따라잡는다.
|
|
@@ -114,23 +140,14 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
|
|
|
114
140
|
// 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
|
|
115
141
|
// 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
|
|
116
142
|
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;
|
|
143
|
+
// 반으로 자를 자리. 이모지처럼 두 칸을 차지하는 글자의 뒷짝 바로 앞에서 끊으면 글자가 깨지므로
|
|
144
|
+
// 그때는 한 칸 옮긴다. (자바스크립트 글자열은 UTF-16 이라 0xDC00~0xDFFF 가 '뒷짝' 이다)
|
|
145
|
+
function splitPoint(text) {
|
|
146
|
+
let mid = text.length >> 1;
|
|
147
|
+
const code = text.charCodeAt(mid);
|
|
148
|
+
if (code >= 0xdc00 && code <= 0xdfff)
|
|
149
|
+
mid += 1;
|
|
150
|
+
return Math.min(Math.max(mid, 1), text.length - 1);
|
|
134
151
|
}
|
|
135
152
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
136
153
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
@@ -243,28 +260,44 @@ export async function runAgent(options) {
|
|
|
243
260
|
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도를 넘고, 넘는 순간부터
|
|
244
261
|
// ack 이 안 돌아와 그 출력이 밀리고 화면이 멈춘다.
|
|
245
262
|
//
|
|
246
|
-
// 간격은 **고정하지 않는다**.
|
|
263
|
+
// 간격은 **고정하지 않는다**. 실패하면 두 배로 벌리고, 실패 없이 시간이 지나면 스스로 좁힌다.
|
|
247
264
|
// 고정값은 "세션 하나에서 잰 값"이라 세션이 늘어나는 순간 그대로 무너졌다 (§상수 주석 참고).
|
|
248
265
|
let channelGapMs = CHANNEL_GAP_START_MS;
|
|
266
|
+
let lastWidenAt = 0;
|
|
267
|
+
// 좁히기는 시간이 지나면 저절로 된다 — 프레임이 오가는 길목에서 확인한다.
|
|
268
|
+
// (예전에는 "성공 한 번에 1ms" 라, 뜸해진 세션은 영영 느린 채로 남았다)
|
|
249
269
|
const easeChannelGap = () => {
|
|
250
|
-
if (channelGapMs
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
if (channelGapMs <= CHANNEL_GAP_MIN_MS)
|
|
271
|
+
return;
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (now - lastWidenAt < CHANNEL_RECOVER_MS)
|
|
274
|
+
return;
|
|
275
|
+
lastWidenAt = now;
|
|
276
|
+
channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, Math.round(channelGapMs * CHANNEL_GAP_SHRINK));
|
|
277
|
+
console.error(`통로가 한동안 조용해서 전송 간격을 ${channelGapMs}ms 로 되돌렸어요`);
|
|
253
278
|
};
|
|
254
279
|
const widenChannelGap = (why) => {
|
|
280
|
+
lastWidenAt = Date.now();
|
|
255
281
|
if (channelGapMs >= CHANNEL_GAP_MAX_MS)
|
|
256
282
|
return;
|
|
257
283
|
channelGapMs = Math.min(CHANNEL_GAP_MAX_MS, Math.max(1, channelGapMs) * CHANNEL_GAP_GROW);
|
|
258
284
|
console.error(`통로가 막혀서 전송 간격을 ${channelGapMs}ms 로 벌렸어요 (${why})`);
|
|
259
285
|
};
|
|
286
|
+
// "이 시각 이후여야 규정 속도에 맞는다" 는 가상의 시계. 부칠 때마다 간격만큼 앞으로 간다.
|
|
287
|
+
// 조용히 있으면 이 시계가 현재보다 뒤처지는데, **그 뒤처진 만큼이 곧 몰아 보낼 여유**다.
|
|
260
288
|
let nextSlotAt = 0;
|
|
261
|
-
// 지금
|
|
262
|
-
// 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (
|
|
263
|
-
const slotLagMs = () => Math.max(0, nextSlotAt - Date.now());
|
|
289
|
+
// 지금 예약이 얼마나 미래로 밀려 있는지 (몰아 보내기 여유를 빼고 센다). 이게 크면 = 통로가
|
|
290
|
+
// 막혔다는 뜻이고, 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (3초씩 밀리던 직접 원인).
|
|
291
|
+
const slotLagMs = () => Math.max(0, nextSlotAt - CHANNEL_BURST_MS - Date.now());
|
|
264
292
|
const takeSendSlot = async () => {
|
|
265
293
|
const now = Date.now();
|
|
266
|
-
|
|
267
|
-
|
|
294
|
+
// 규정상 나갈 시각. 조용했으면 지금이고, 몰아 보내는 중이면 앞선 것들 뒤로 밀린다.
|
|
295
|
+
const due = Math.max(now, nextSlotAt);
|
|
296
|
+
nextSlotAt = due + channelGapMs;
|
|
297
|
+
// 규정보다 CHANNEL_BURST_MS 만큼 앞질러 나가는 것은 봐준다 — 조각 몇 개는 간격 없이 곧바로
|
|
298
|
+
// 나가고, 여유를 다 쓴 뒤에야 꾸준한 속도로 묶인다.
|
|
299
|
+
// (예약을 **부르는 즉시** 잡아두므로 부른 순서 = 나가는 순서는 그대로 지켜진다)
|
|
300
|
+
const at = Math.max(now, due - CHANNEL_BURST_MS);
|
|
268
301
|
if (at > now)
|
|
269
302
|
await new Promise((resolve) => setTimeout(resolve, at - now));
|
|
270
303
|
};
|
|
@@ -298,17 +331,42 @@ export async function runAgent(options) {
|
|
|
298
331
|
session.sendChain = next.then(() => undefined, () => undefined);
|
|
299
332
|
return next;
|
|
300
333
|
};
|
|
301
|
-
// 실제로 쓰는 조각
|
|
302
|
-
// 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
303
|
-
// 재시도 전에 반드시 크기를 줄여야 한다.
|
|
304
|
-
let
|
|
305
|
-
const
|
|
306
|
-
if (
|
|
334
|
+
// 실제로 쓰는 조각 크기 (통로에 실리는 크기 기준). 서버가 "너무 크다" 고 거부하면 절반으로
|
|
335
|
+
// 줄여서 스스로 맞춘다 — 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
336
|
+
// (= 검은 화면이 영영 안 풀린다), 재시도 전에 반드시 크기를 줄여야 한다.
|
|
337
|
+
let wireBudget = WIRE_BUDGET_BYTES;
|
|
338
|
+
const shrinkWireBudget = (why) => {
|
|
339
|
+
if (wireBudget <= MIN_WIRE_BUDGET_BYTES)
|
|
307
340
|
return false;
|
|
308
|
-
|
|
309
|
-
console.error(`조각 크기를 ${Math.round(
|
|
341
|
+
wireBudget = Math.max(MIN_WIRE_BUDGET_BYTES, Math.floor(wireBudget / 2));
|
|
342
|
+
console.error(`조각 크기를 ${Math.round(wireBudget / 1024)}KB 로 줄였어요 (${why})`);
|
|
310
343
|
return true;
|
|
311
344
|
};
|
|
345
|
+
// 보낼 글을 프레임 조각들로 만든다 — **압축·암호화까지 실제로 해보고 크기를 재서** 나눈다.
|
|
346
|
+
//
|
|
347
|
+
// 압축을 넣으면 "원본 몇 바이트로 자를까" 는 답이 없는 질문이 된다. 같은 40KB라도 색이 촘촘한
|
|
348
|
+
// 화면과 회전 표시만 도는 화면은 압축 후 크기가 열 배 넘게 차이 난다. 짐작해서 자르면 크게
|
|
349
|
+
// 잡을 땐 한도를 넘어 통째로 거부되고(폰은 새까만 화면), 작게 잡을 땐 한 조각이면 될 것을
|
|
350
|
+
// 다섯 조각으로 쪼갠다(조각 사이 틈 = 검은 화면, 유실 확률도 조각 수만큼).
|
|
351
|
+
//
|
|
352
|
+
// 그래서 짐작하지 않는다. 통째로 담아보고, 안 들어가면 반으로 잘라 다시 담아본다.
|
|
353
|
+
// 잘 압축되는 화면은 그대로 **한 조각**으로 끝난다 (실측: 25KB 출력 → 7KB, 약 4.8배).
|
|
354
|
+
const packOutput = (session, text) => {
|
|
355
|
+
const pack = (part, depth) => {
|
|
356
|
+
// 타자 한 글자가 되비치는 것까지 압축하면 머리말 때문에 오히려 커진다 — 큰 것만 압축한다
|
|
357
|
+
const useGz = session.gzipOk && Buffer.byteLength(part, 'utf8') >= GZIP_MIN_BYTES;
|
|
358
|
+
const data = useGz
|
|
359
|
+
? encryptBytes(compress(part), session.frameKey)
|
|
360
|
+
: encryptFrame(part, session.frameKey);
|
|
361
|
+
// 한 글자까지 줄여도 안 들어가면 더 쪼갤 수가 없다 — 그냥 보내고 결과에 맡긴다
|
|
362
|
+
if (data.length <= wireBudget || part.length <= 1 || depth >= MAX_PACK_DEPTH) {
|
|
363
|
+
return [useGz ? { data, gz: true } : { data }];
|
|
364
|
+
}
|
|
365
|
+
const cut = splitPoint(part);
|
|
366
|
+
return [...pack(part.slice(0, cut), depth + 1), ...pack(part.slice(cut), depth + 1)];
|
|
367
|
+
};
|
|
368
|
+
return text ? pack(text, 0) : [];
|
|
369
|
+
};
|
|
312
370
|
// 지금까지 나온 출력이 '보이지 않는 터미널' 에 다 반영되기를 기다린다.
|
|
313
371
|
// (xterm 은 받은 글자를 곧바로가 아니라 조금 뒤에 처리한다 — 실측 확인)
|
|
314
372
|
const drainScreen = (session) => new Promise((resolve) => {
|
|
@@ -341,7 +399,8 @@ export async function runAgent(options) {
|
|
|
341
399
|
const results = await Promise.allSettled(parts.map((part, index) => sendFrame({
|
|
342
400
|
kind: 'output',
|
|
343
401
|
to: session.deviceId,
|
|
344
|
-
data:
|
|
402
|
+
data: part.data,
|
|
403
|
+
...(part.gz ? { gz: true } : {}),
|
|
345
404
|
// 복원 조각임을 표시해서, 폰이 다 모은 뒤 한 번에 그리게 한다 (검은 화면 방지)
|
|
346
405
|
seq: index,
|
|
347
406
|
total: parts.length,
|
|
@@ -355,7 +414,7 @@ export async function runAgent(options) {
|
|
|
355
414
|
const message = String(reason?.message ?? reason);
|
|
356
415
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
357
416
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
358
|
-
|
|
417
|
+
shrinkWireBudget(message);
|
|
359
418
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
360
419
|
}
|
|
361
420
|
return sent;
|
|
@@ -384,15 +443,17 @@ export async function runAgent(options) {
|
|
|
384
443
|
// 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
|
|
385
444
|
await enqueueSend(session, async () => {
|
|
386
445
|
try {
|
|
387
|
-
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
|
|
388
|
-
|
|
446
|
+
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다).
|
|
447
|
+
// 압축이 되는 폰이면 대개 한 조각으로 끝난다 — 조각이 적을수록 빠르고 덜 유실된다.
|
|
448
|
+
const parts = packOutput(session, pending);
|
|
389
449
|
session.inFlight += parts.length;
|
|
390
450
|
// send() 를 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
|
|
391
451
|
// 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
|
|
392
452
|
const results = parts.map((part) => sendFrame({
|
|
393
453
|
kind: 'output',
|
|
394
454
|
to: session.deviceId,
|
|
395
|
-
data:
|
|
455
|
+
data: part.data,
|
|
456
|
+
...(part.gz ? { gz: true } : {}),
|
|
396
457
|
}));
|
|
397
458
|
void Promise.allSettled(results)
|
|
398
459
|
.then((settled) => {
|
|
@@ -405,7 +466,7 @@ export async function runAgent(options) {
|
|
|
405
466
|
const message = String(reason?.message ?? reason);
|
|
406
467
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
407
468
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
408
|
-
|
|
469
|
+
shrinkWireBudget(message);
|
|
409
470
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
410
471
|
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 맞춘다**.
|
|
411
472
|
// 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
|
|
@@ -498,7 +559,7 @@ export async function runAgent(options) {
|
|
|
498
559
|
}
|
|
499
560
|
// 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
|
|
500
561
|
const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
|
|
501
|
-
const parts =
|
|
562
|
+
const parts = packOutput(session, payload);
|
|
502
563
|
const sent = await enqueueSend(session, () => sendParts(session, parts));
|
|
503
564
|
if (sent < parts.length) {
|
|
504
565
|
// 화면을 지우라는 첫 조각만 도착하고 내용이 끊긴 상태 = 폰에는 **새까만 화면**만 남는다.
|
|
@@ -536,7 +597,7 @@ export async function runAgent(options) {
|
|
|
536
597
|
// 두 번 연달아 실패하면 이유가 뭐라고 적혀 있든 크기부터 의심한다 — 같은 크기로 계속
|
|
537
598
|
// 다시 보내봐야 같은 이유로 막히고, 그동안 폰은 계속 새까만 화면이다.
|
|
538
599
|
if (session.restoreFails >= 2)
|
|
539
|
-
|
|
600
|
+
shrinkWireBudget('화면 복원이 거듭 실패');
|
|
540
601
|
console.error(`화면 복원이 중간에 끊겼어요 ("${session.name}") — ${wait}ms 뒤 다시 보낼게요.`);
|
|
541
602
|
session.restoreRetry = setTimeout(() => {
|
|
542
603
|
session.restoreRetry = null;
|
|
@@ -714,6 +775,8 @@ export async function runAgent(options) {
|
|
|
714
775
|
hookSeen: false,
|
|
715
776
|
temporary,
|
|
716
777
|
viewerSeenAt: Date.now(),
|
|
778
|
+
// 폰이 접속하면서 "압축 읽을 수 있다" 고 알려주기 전까지는 압축하지 않는다
|
|
779
|
+
gzipOk: false,
|
|
717
780
|
};
|
|
718
781
|
session.screen.loadAddon(session.serializer);
|
|
719
782
|
session.pty.onData((data) => {
|
|
@@ -880,6 +943,13 @@ export async function runAgent(options) {
|
|
|
880
943
|
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리·출력 게이트에 쓴다)
|
|
881
944
|
const wasUnwatched = !hasViewer(session);
|
|
882
945
|
session.viewerSeenAt = Date.now();
|
|
946
|
+
// 압축된 화면을 읽을 수 있는 폰인지. 접속할 때(connect_request)는 **그 폰이 말한 그대로** 맞춘다 —
|
|
947
|
+
// 옛 폰이 새로 붙으면 압축을 꺼야 하기 때문이다(못 읽는 폰에 보내면 화면이 통째로 깨진다).
|
|
948
|
+
// 그 밖의 프레임에서는 켜기만 한다(핑·크기변경에는 이 표시가 없을 수 있으므로 끄면 안 된다).
|
|
949
|
+
if (frame.kind === 'connect_request')
|
|
950
|
+
session.gzipOk = frame.gzOk === true;
|
|
951
|
+
else if (frame.gzOk)
|
|
952
|
+
session.gzipOk = true;
|
|
883
953
|
// 안 보는 동안에는 실시간 프레임을 만들지 않으므로(pty.onData 참고), 다시 보기 시작하면
|
|
884
954
|
// 그 사이 화면이 어떻게 바뀌었는지 알 길이 없다 — 지금 화면을 통째로 보내 맞춰준다.
|
|
885
955
|
// connect_request 는 아래에서 어차피 복원하므로 여기서 두 번 하지 않는다.
|
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
|
}
|