@sanohiro/casty 1.1.0 → 1.1.2
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.ja.md +15 -0
- package/README.md +15 -0
- package/lib/browser.js +85 -10
- package/lib/config.js +7 -5
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
casty は w3m や lynx のようなテキストブラウザではありません。ヘッドレス Chrome を起動し、CDP でレンダリング結果を取得して、Kitty graphics protocol でターミナルに描画します。Chrome のリモートデスクトップがターミナルに収まった感じです。
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
<video src="https://github.com/user-attachments/assets/552f1972-bb53-481e-9516-c36b7e5085d8" autoplay loop muted playsinline></video>
|
|
10
12
|
|
|
11
13
|
## 仕組み
|
|
@@ -164,6 +166,19 @@ lib/bookmarks.js ブックマーク検索
|
|
|
164
166
|
|
|
165
167
|
## トラブルシューティング
|
|
166
168
|
|
|
169
|
+
### YouTube で音が出ない(Ubuntu Server)
|
|
170
|
+
|
|
171
|
+
Chrome はシステムのオーディオサーバーを通じて直接音声を再生します。音が出ない場合:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
sudo apt install pulseaudio
|
|
175
|
+
sudo usermod -aG audio $USER
|
|
176
|
+
# ログアウトして再ログイン後:
|
|
177
|
+
pulseaudio --start
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Chrome がクラッシュする
|
|
181
|
+
|
|
167
182
|
casty が起動しない、または Chrome がクラッシュする場合、ブラウザキャッシュを削除してください:
|
|
168
183
|
|
|
169
184
|
```bash
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Run a real Chrome browser inside your terminal.
|
|
|
6
6
|
|
|
7
7
|
casty is not a text-mode browser like w3m or lynx. It launches headless Chrome, grabs the rendered frames over CDP, and draws them in your terminal via Kitty graphics protocol. Think of it as a remote desktop for Chrome that fits in a terminal window.
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
<video src="https://github.com/user-attachments/assets/552f1972-bb53-481e-9516-c36b7e5085d8" autoplay loop muted playsinline></video>
|
|
10
12
|
|
|
11
13
|
## How It Works
|
|
@@ -164,6 +166,19 @@ lib/bookmarks.js Bookmark search
|
|
|
164
166
|
|
|
165
167
|
## Troubleshooting
|
|
166
168
|
|
|
169
|
+
### No audio on YouTube (Ubuntu Server)
|
|
170
|
+
|
|
171
|
+
Chrome plays audio directly through the system audio server. If there's no sound:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
sudo apt install pulseaudio
|
|
175
|
+
sudo usermod -aG audio $USER
|
|
176
|
+
# Log out and back in, then:
|
|
177
|
+
pulseaudio --start
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Chrome crashes
|
|
181
|
+
|
|
167
182
|
If casty fails to start or Chrome crashes, try removing the browser cache:
|
|
168
183
|
|
|
169
184
|
```bash
|
package/lib/browser.js
CHANGED
|
@@ -7,8 +7,20 @@ import { CDPClient } from './cdp.js';
|
|
|
7
7
|
import { launchChrome } from './chrome.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const DEFAULT_CHROME_VERSION = '146.0.7680.80';
|
|
11
|
+
|
|
12
|
+
// Platform-specific identity (must match real OS to avoid TLS/TCP fingerprint mismatch)
|
|
13
|
+
const PLATFORM = process.platform === 'darwin'
|
|
14
|
+
? { ua: 'Macintosh; Intel Mac OS X 10_15_7', nav: 'MacIntel',
|
|
15
|
+
uaPlatform: 'macOS', uaPlatformVersion: '15.0.0',
|
|
16
|
+
glVendor: 'Google Inc. (Apple)', glRenderer: 'ANGLE (Apple, Apple M1, OpenGL 4.1)' }
|
|
17
|
+
: { ua: `X11; Linux ${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}`,
|
|
18
|
+
nav: `Linux ${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}`,
|
|
19
|
+
uaPlatform: 'Linux', uaPlatformVersion: '',
|
|
20
|
+
glVendor: 'Google Inc. (Intel)', glRenderer: 'ANGLE (Intel, Mesa Intel UHD Graphics, OpenGL 4.5)' };
|
|
21
|
+
|
|
22
|
+
const buildUserAgent = (v) =>
|
|
23
|
+
`Mozilla/5.0 (${PLATFORM.ua}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${v} Safari/537.36`;
|
|
12
24
|
const PROFILE_DIR = join(homedir(), '.casty', 'profile');
|
|
13
25
|
|
|
14
26
|
// Screencast settings (low-res stream for change detection)
|
|
@@ -21,7 +33,7 @@ const CAPTURE_STUCK_RESET = 5000; // Reset stuck capturing flag (ms)
|
|
|
21
33
|
|
|
22
34
|
// Build stealth script with locale-dependent language settings
|
|
23
35
|
// lang: primary language (e.g. "ja", "en-US")
|
|
24
|
-
function buildStealthScript(lang) {
|
|
36
|
+
function buildStealthScript(lang, { fakeMedia = false } = {}) {
|
|
25
37
|
// Build Accept-Language style list: primary, then en-US/en fallbacks
|
|
26
38
|
const languages = [lang];
|
|
27
39
|
if (lang !== 'en-US' && lang !== 'en') {
|
|
@@ -36,9 +48,8 @@ function buildStealthScript(lang) {
|
|
|
36
48
|
Object.defineProperty(navigator, 'plugins', {
|
|
37
49
|
get: () => {
|
|
38
50
|
const arr = [
|
|
39
|
-
{ name: '
|
|
40
|
-
{ name: 'Chrome PDF Viewer', filename: '
|
|
41
|
-
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
|
|
51
|
+
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 },
|
|
52
|
+
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 },
|
|
42
53
|
];
|
|
43
54
|
arr.refresh = () => {};
|
|
44
55
|
return arr;
|
|
@@ -82,6 +93,9 @@ navigator.permissions.query = (params) => {
|
|
|
82
93
|
if (params.name === 'notifications') {
|
|
83
94
|
return Promise.resolve({ state: Notification.permission });
|
|
84
95
|
}
|
|
96
|
+
${fakeMedia ? ` if (params.name === 'camera' || params.name === 'microphone') {
|
|
97
|
+
return Promise.resolve({ state: 'granted' });
|
|
98
|
+
}` : ''}
|
|
85
99
|
return origQuery(params);
|
|
86
100
|
};
|
|
87
101
|
|
|
@@ -89,8 +103,8 @@ navigator.permissions.query = (params) => {
|
|
|
89
103
|
// 0x9245 = UNMASKED_VENDOR_WEBGL, 0x9246 = UNMASKED_RENDERER_WEBGL
|
|
90
104
|
const getParam = WebGLRenderingContext.prototype.getParameter;
|
|
91
105
|
WebGLRenderingContext.prototype.getParameter = function(p) {
|
|
92
|
-
if (p === 0x9245) return '
|
|
93
|
-
if (p === 0x9246) return '
|
|
106
|
+
if (p === 0x9245) return '${PLATFORM.glVendor}';
|
|
107
|
+
if (p === 0x9246) return '${PLATFORM.glRenderer}';
|
|
94
108
|
return getParam.call(this, p);
|
|
95
109
|
};
|
|
96
110
|
|
|
@@ -100,6 +114,43 @@ if (!navigator.connection) {
|
|
|
100
114
|
get: () => ({ effectiveType: '4g', rtt: 50, downlink: 10, saveData: false }),
|
|
101
115
|
});
|
|
102
116
|
}
|
|
117
|
+
|
|
118
|
+
${fakeMedia ? `
|
|
119
|
+
// getUserMedia emulation (headless-shell lacks media capture)
|
|
120
|
+
if (navigator.mediaDevices) {
|
|
121
|
+
const fakeStream = (constraints) => {
|
|
122
|
+
const tracks = [];
|
|
123
|
+
if (constraints.video) {
|
|
124
|
+
const w = constraints.video.width?.ideal || constraints.video.width?.max || 640;
|
|
125
|
+
const h = constraints.video.height?.ideal || constraints.video.height?.max || 480;
|
|
126
|
+
const canvas = document.createElement('canvas');
|
|
127
|
+
canvas.width = w;
|
|
128
|
+
canvas.height = h;
|
|
129
|
+
const ctx = canvas.getContext('2d');
|
|
130
|
+
const draw = () => { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h); requestAnimationFrame(draw); };
|
|
131
|
+
draw();
|
|
132
|
+
const stream = canvas.captureStream(30);
|
|
133
|
+
tracks.push(...stream.getVideoTracks());
|
|
134
|
+
}
|
|
135
|
+
if (constraints.audio) {
|
|
136
|
+
const actx = new AudioContext();
|
|
137
|
+
const gain = actx.createGain();
|
|
138
|
+
gain.gain.value = 0;
|
|
139
|
+
gain.connect(actx.destination);
|
|
140
|
+
const dest = actx.createMediaStreamDestination();
|
|
141
|
+
gain.connect(dest);
|
|
142
|
+
tracks.push(...dest.stream.getAudioTracks());
|
|
143
|
+
}
|
|
144
|
+
return Promise.resolve(new MediaStream(tracks));
|
|
145
|
+
};
|
|
146
|
+
navigator.mediaDevices.getUserMedia = fakeStream;
|
|
147
|
+
navigator.mediaDevices.enumerateDevices = () => Promise.resolve([
|
|
148
|
+
{ deviceId: 'default', kind: 'audioinput', label: 'Default Microphone', groupId: 'default' },
|
|
149
|
+
{ deviceId: 'default', kind: 'videoinput', label: 'Default Camera', groupId: 'default' },
|
|
150
|
+
{ deviceId: 'default', kind: 'audiooutput', label: 'Default Speaker', groupId: 'default' },
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
` : ''}
|
|
103
154
|
`;
|
|
104
155
|
}
|
|
105
156
|
|
|
@@ -132,6 +183,11 @@ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {
|
|
|
132
183
|
const lang = config.language;
|
|
133
184
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
134
185
|
|
|
186
|
+
// Detect Chrome version from CDP endpoint
|
|
187
|
+
const versionInfo = await fetchJson(`${baseUrl}/json/version`);
|
|
188
|
+
const chromeVersion = versionInfo?.Browser?.replace(/^.*\//, '') || DEFAULT_CHROME_VERSION;
|
|
189
|
+
const userAgent = buildUserAgent(chromeVersion);
|
|
190
|
+
|
|
135
191
|
// Try /json/new (single HTTP request, fastest)
|
|
136
192
|
let target = await fetchJson(`${baseUrl}/json/new?about:blank`);
|
|
137
193
|
if (!target?.webSocketDebuggerUrl) {
|
|
@@ -162,10 +218,29 @@ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {
|
|
|
162
218
|
width: cssWidth, height: cssHeight, deviceScaleFactor: zoom, mobile: false,
|
|
163
219
|
}),
|
|
164
220
|
client.send('Network.setUserAgentOverride', {
|
|
165
|
-
userAgent:
|
|
221
|
+
userAgent: userAgent, platform: PLATFORM.nav,
|
|
166
222
|
acceptLanguage: buildAcceptLanguage(lang),
|
|
223
|
+
userAgentMetadata: {
|
|
224
|
+
brands: [
|
|
225
|
+
{ brand: 'Chromium', version: chromeVersion.split('.')[0] },
|
|
226
|
+
{ brand: 'Google Chrome', version: chromeVersion.split('.')[0] },
|
|
227
|
+
{ brand: 'Not-A.Brand', version: '99' },
|
|
228
|
+
],
|
|
229
|
+
fullVersionList: [
|
|
230
|
+
{ brand: 'Chromium', version: chromeVersion },
|
|
231
|
+
{ brand: 'Google Chrome', version: chromeVersion },
|
|
232
|
+
{ brand: 'Not-A.Brand', version: '99.0.0.0' },
|
|
233
|
+
],
|
|
234
|
+
platform: PLATFORM.uaPlatform,
|
|
235
|
+
platformVersion: PLATFORM.uaPlatformVersion,
|
|
236
|
+
architecture: process.arch === 'arm64' ? 'arm' : 'x86',
|
|
237
|
+
bitness: '64',
|
|
238
|
+
model: '',
|
|
239
|
+
mobile: false,
|
|
240
|
+
wow64: false,
|
|
241
|
+
},
|
|
167
242
|
}),
|
|
168
|
-
client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang) }),
|
|
243
|
+
client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang, { fakeMedia: config.fakeMedia }) }),
|
|
169
244
|
client.send('Browser.setDownloadBehavior', {
|
|
170
245
|
behavior: 'allowAndName', downloadPath: join(homedir(), 'Downloads'),
|
|
171
246
|
eventsEnabled: true,
|
package/lib/config.js
CHANGED
|
@@ -15,15 +15,16 @@ const configPath = join(homedir(), '.casty', 'config.json');
|
|
|
15
15
|
|
|
16
16
|
// Detect system locale (e.g. "en-US", "ja", "zh-CN")
|
|
17
17
|
function detectLocale() {
|
|
18
|
-
// Intl
|
|
18
|
+
// Environment variables first (Intl may lack ICU data and return en-US)
|
|
19
|
+
const env = process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || '';
|
|
20
|
+
const m = env.match(/^([a-z]{2})(?:[_-]([A-Z]{2}))?/);
|
|
21
|
+
if (m) return m[2] ? `${m[1]}-${m[2]}` : m[1];
|
|
22
|
+
// Fallback to Intl API
|
|
19
23
|
try {
|
|
20
24
|
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
21
25
|
if (locale) return locale;
|
|
22
26
|
} catch {}
|
|
23
|
-
|
|
24
|
-
const env = process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || '';
|
|
25
|
-
const m = env.match(/^([a-z]{2}(?:[_-][A-Z]{2})?)/);
|
|
26
|
-
return m ? m[1].replace('_', '-') : 'en-US';
|
|
27
|
+
return 'en-US';
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const DEFAULTS = {
|
|
@@ -31,6 +32,7 @@ const DEFAULTS = {
|
|
|
31
32
|
searchUrl: 'https://www.google.com/search?q=',
|
|
32
33
|
transport: 'auto', // 'auto' | 'file' | 'inline'
|
|
33
34
|
format: 'auto', // 'auto' | 'png' | 'jpeg'
|
|
35
|
+
fakeMedia: false, // Emulate camera/mic for WebRTC (headless-shell lacks getUserMedia)
|
|
34
36
|
language: detectLocale(), // System locale (override: "en-US", "ja", etc.)
|
|
35
37
|
};
|
|
36
38
|
|