shellbase 0.3.2 → 0.5.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/dist/agent.js +98 -4
- package/dist/browse.js +15 -1
- package/dist/devices.js +4 -0
- package/dist/files.js +69 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -7,10 +7,11 @@ import { requireClient } from './auth.js';
|
|
|
7
7
|
import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
|
|
8
8
|
import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
|
|
9
9
|
import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
|
|
10
|
-
import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineDirs } from './devices.js';
|
|
10
|
+
import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineDirs, renameDevice, } from './devices.js';
|
|
11
11
|
import { promptApproval } from './prompt.js';
|
|
12
12
|
import { touchRecentDir } from './recent-dirs.js';
|
|
13
13
|
import { listDirs } from './browse.js';
|
|
14
|
+
import { readTextFile, writeTextFile, chunkContent } from './files.js';
|
|
14
15
|
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
15
16
|
const HEARTBEAT_MS = 20_000;
|
|
16
17
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
@@ -25,6 +26,8 @@ const SEND_PENALTY_MS = 500;
|
|
|
25
26
|
const PENALTY_INTERVAL_MS = 250;
|
|
26
27
|
// 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
|
|
27
28
|
const MAX_SESSIONS = 20;
|
|
29
|
+
// 저장 중인 파일 조각을 모아두는 시간 — 중간에 폰이 끊기면 조용히 버린다
|
|
30
|
+
const WRITE_BUFFER_TTL_MS = 60_000;
|
|
28
31
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
29
32
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
30
33
|
// 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
|
|
@@ -41,9 +44,6 @@ function resolveShell() {
|
|
|
41
44
|
return process.env.COMSPEC ?? 'powershell.exe';
|
|
42
45
|
return process.env.SHELL ?? '/bin/bash';
|
|
43
46
|
}
|
|
44
|
-
// 폰이 보내는 제어 프레임(끄기 / 새 세션 / 폴더 목록)은 그 세션의 키로 풀려야만 받아들인다.
|
|
45
|
-
// 키는 devices 테이블의 내 row 에만 있고 RLS 로 주인만 읽을 수 있으니, 복호화에 성공했다는 것 자체가
|
|
46
|
-
// "주인이 보낸 요청"이라는 증거가 된다 (입력 승인과는 별개 — 승인은 타이핑 권한용).
|
|
47
47
|
function readControl(data, key) {
|
|
48
48
|
if (typeof data !== 'string')
|
|
49
49
|
return null;
|
|
@@ -75,6 +75,8 @@ export async function runAgent(options) {
|
|
|
75
75
|
const hostLabel = options.name ?? os.hostname();
|
|
76
76
|
const shell = resolveShell();
|
|
77
77
|
const sessions = new Map();
|
|
78
|
+
// 폰에서 저장 중인 파일 조각 모음 (경로별)
|
|
79
|
+
const writeBuffers = new Map();
|
|
78
80
|
function defaultName(cwd) {
|
|
79
81
|
return cwd === os.homedir() ? hostLabel : `${hostLabel} · ${path.basename(cwd)}`;
|
|
80
82
|
}
|
|
@@ -315,6 +317,98 @@ export async function runAgent(options) {
|
|
|
315
317
|
void closeSession(session, { deliberate: true });
|
|
316
318
|
return;
|
|
317
319
|
}
|
|
320
|
+
// 폰에서 세션 이름 바꾸기 — 컴퓨터를 가리키는 앞부분은 유지해서 목록의 컴퓨터 묶음이 깨지지 않게 한다
|
|
321
|
+
if (frame.kind === 'rename') {
|
|
322
|
+
const control = readControl(frame.data, session.frameKey);
|
|
323
|
+
if (!control || typeof control.name !== 'string')
|
|
324
|
+
return;
|
|
325
|
+
const label = control.name.trim().slice(0, 40);
|
|
326
|
+
if (!label)
|
|
327
|
+
return;
|
|
328
|
+
const nextName = `${hostLabel} · ${label}`;
|
|
329
|
+
const before = session.name;
|
|
330
|
+
session.name = nextName;
|
|
331
|
+
renameDevice(cb, session.rowId, nextName)
|
|
332
|
+
.then(() => {
|
|
333
|
+
// 재시작 복구 목록에도 바뀐 이름이 남도록 저장
|
|
334
|
+
persistOpenSessions();
|
|
335
|
+
console.log(`📱 세션 이름을 바꿨어요: "${before}" → "${nextName}"`);
|
|
336
|
+
})
|
|
337
|
+
.catch((err) => {
|
|
338
|
+
session.name = before;
|
|
339
|
+
console.error('이름 변경 실패:', err.message);
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// 폰에서 파일 열기 — 실시간 프레임 크기 제한(20KB 남짓) 때문에 조각으로 나눠 보낸다
|
|
344
|
+
if (frame.kind === 'read_file') {
|
|
345
|
+
const control = readControl(frame.data, session.frameKey);
|
|
346
|
+
if (!control || typeof control.path !== 'string')
|
|
347
|
+
return;
|
|
348
|
+
const filePath = control.path;
|
|
349
|
+
const result = readTextFile(filePath);
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
void send({
|
|
352
|
+
kind: 'file_error',
|
|
353
|
+
to: session.deviceId,
|
|
354
|
+
data: encryptFrame(JSON.stringify({ path: filePath, reason: result.reason }), session.frameKey),
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const chunks = chunkContent(result.content);
|
|
359
|
+
void (async () => {
|
|
360
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
361
|
+
await send({
|
|
362
|
+
kind: 'file_chunk',
|
|
363
|
+
to: session.deviceId,
|
|
364
|
+
data: encryptFrame(JSON.stringify({ path: filePath, index, total: chunks.length, chunk: chunks[index] }), session.frameKey),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// 폰에서 파일 저장 — 조각이 다 모이면 임시 파일에 쓴 뒤 교체한다 (files.ts)
|
|
371
|
+
if (frame.kind === 'write_file') {
|
|
372
|
+
const control = readControl(frame.data, session.frameKey);
|
|
373
|
+
if (!control ||
|
|
374
|
+
typeof control.path !== 'string' ||
|
|
375
|
+
typeof control.chunk !== 'string' ||
|
|
376
|
+
typeof control.index !== 'number' ||
|
|
377
|
+
typeof control.total !== 'number') {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const key = `${session.deviceId}:${control.path}`;
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
for (const [bufferKey, buffer] of writeBuffers) {
|
|
383
|
+
if (now - buffer.at > WRITE_BUFFER_TTL_MS)
|
|
384
|
+
writeBuffers.delete(bufferKey);
|
|
385
|
+
}
|
|
386
|
+
const buffer = control.index === 0
|
|
387
|
+
? { parts: [], total: control.total, at: now }
|
|
388
|
+
: (writeBuffers.get(key) ?? { parts: [], total: control.total, at: now });
|
|
389
|
+
buffer.parts[control.index] = control.chunk;
|
|
390
|
+
buffer.at = now;
|
|
391
|
+
writeBuffers.set(key, buffer);
|
|
392
|
+
if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
|
|
393
|
+
return;
|
|
394
|
+
writeBuffers.delete(key);
|
|
395
|
+
const filePath = control.path;
|
|
396
|
+
const result = writeTextFile(filePath, buffer.parts.join(''));
|
|
397
|
+
void send(result.ok
|
|
398
|
+
? {
|
|
399
|
+
kind: 'file_saved',
|
|
400
|
+
to: session.deviceId,
|
|
401
|
+
data: encryptFrame(JSON.stringify({ path: filePath }), session.frameKey),
|
|
402
|
+
}
|
|
403
|
+
: {
|
|
404
|
+
kind: 'file_error',
|
|
405
|
+
to: session.deviceId,
|
|
406
|
+
data: encryptFrame(JSON.stringify({ path: filePath, reason: result.reason }), session.frameKey),
|
|
407
|
+
});
|
|
408
|
+
if (result.ok)
|
|
409
|
+
console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
318
412
|
if (frame.kind === 'list_dirs') {
|
|
319
413
|
const control = readControl(frame.data, session.frameKey);
|
|
320
414
|
if (!control)
|
package/dist/browse.js
CHANGED
|
@@ -12,6 +12,7 @@ export function listDirs(dir) {
|
|
|
12
12
|
parent: up === target ? null : up,
|
|
13
13
|
home: os.homedir(),
|
|
14
14
|
entries: [],
|
|
15
|
+
files: [],
|
|
15
16
|
};
|
|
16
17
|
let items;
|
|
17
18
|
try {
|
|
@@ -40,9 +41,22 @@ export function listDirs(dir) {
|
|
|
40
41
|
})
|
|
41
42
|
.map((entry) => entry.name)
|
|
42
43
|
.sort((a, b) => a.localeCompare(b));
|
|
44
|
+
// 파일은 이름과 크기만 — 열어볼 수 있는지 폰에서 미리 판단할 수 있게 한다
|
|
45
|
+
const files = items
|
|
46
|
+
.filter((entry) => !entry.name.startsWith('.') && entry.isFile())
|
|
47
|
+
.map((entry) => {
|
|
48
|
+
try {
|
|
49
|
+
return { name: entry.name, size: fs.statSync(path.join(target, entry.name)).size };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { name: entry.name, size: 0 };
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
43
56
|
return {
|
|
44
57
|
...base,
|
|
45
58
|
entries: names.slice(0, MAX_ENTRIES),
|
|
46
|
-
|
|
59
|
+
files: files.slice(0, MAX_ENTRIES),
|
|
60
|
+
truncated: names.length > MAX_ENTRIES || files.length > MAX_ENTRIES,
|
|
47
61
|
};
|
|
48
62
|
}
|
package/dist/devices.js
CHANGED
|
@@ -46,3 +46,7 @@ export async function listOnlineDirs(cb) {
|
|
|
46
46
|
}
|
|
47
47
|
return dirs;
|
|
48
48
|
}
|
|
49
|
+
// 폰에서 세션 이름을 바꿀 때 — 목록 row 의 표시 이름만 갱신한다 (세션 자체는 그대로 유지)
|
|
50
|
+
export async function renameDevice(cb, rowId, deviceName) {
|
|
51
|
+
await cb.database.updateData(DEVICES_TABLE_ID, rowId, { data: { device_name: deviceName } });
|
|
52
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
// 폰에서 파일을 열어보고 고칠 수 있게 하는 읽기/쓰기. 실시간 프레임은 한 번에 20KB 남짓만 보낼 수 있어서
|
|
5
|
+
// (실측: 16KB 성공 / 64KB 거부) 파일은 조각으로 나눠 주고받는다.
|
|
6
|
+
export const CHUNK_SIZE = 12 * 1024;
|
|
7
|
+
export const MAX_FILE_SIZE = 512 * 1024;
|
|
8
|
+
function expand(target) {
|
|
9
|
+
if (target.startsWith('~'))
|
|
10
|
+
return path.join(os.homedir(), target.slice(1));
|
|
11
|
+
return path.resolve(target);
|
|
12
|
+
}
|
|
13
|
+
export function readTextFile(target) {
|
|
14
|
+
const full = expand(target);
|
|
15
|
+
let stat;
|
|
16
|
+
try {
|
|
17
|
+
stat = fs.statSync(full);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { ok: false, reason: `파일을 찾을 수 없어요: ${full}` };
|
|
21
|
+
}
|
|
22
|
+
if (stat.isDirectory())
|
|
23
|
+
return { ok: false, reason: '폴더는 열 수 없어요.' };
|
|
24
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: `파일이 너무 커요 (${Math.round(stat.size / 1024)}KB) — ${MAX_FILE_SIZE / 1024}KB 까지만 열 수 있어요.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
let buffer;
|
|
31
|
+
try {
|
|
32
|
+
buffer = fs.readFileSync(full);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { ok: false, reason: `파일을 읽지 못했어요: ${err.message}` };
|
|
36
|
+
}
|
|
37
|
+
// 사진·실행파일 같은 이진 파일은 에디터에서 의미가 없고 깨져 보이기만 하므로 미리 거른다
|
|
38
|
+
if (buffer.subarray(0, 8192).includes(0)) {
|
|
39
|
+
return { ok: false, reason: '글자 파일이 아니에요 (이진 파일은 열 수 없어요).' };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, content: buffer.toString('utf8'), size: stat.size };
|
|
42
|
+
}
|
|
43
|
+
export function writeTextFile(target, content) {
|
|
44
|
+
const full = expand(target);
|
|
45
|
+
try {
|
|
46
|
+
// 같은 폴더에 임시 파일로 먼저 쓰고 교체 — 저장 도중 끊겨도 원본이 깨지지 않는다
|
|
47
|
+
const tmp = path.join(path.dirname(full), `.${path.basename(full)}.shellbase-tmp`);
|
|
48
|
+
let mode;
|
|
49
|
+
try {
|
|
50
|
+
mode = fs.statSync(full).mode;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 새 파일이면 기본 권한
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(tmp, content, mode === undefined ? undefined : { mode });
|
|
56
|
+
fs.renameSync(tmp, full);
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { ok: false, reason: `저장하지 못했어요: ${err.message}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function chunkContent(content) {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
for (let i = 0; i < content.length; i += CHUNK_SIZE) {
|
|
66
|
+
chunks.push(content.slice(i, i + CHUNK_SIZE));
|
|
67
|
+
}
|
|
68
|
+
return chunks.length > 0 ? chunks : [''];
|
|
69
|
+
}
|