Compare commits

..

3 Commits

Author SHA1 Message Date
Никита Sonic f82543e44e ci(android): retry UIAutomator dumps that write nothing (#64)
On the loaded API 35 emulator `uiautomator dump` intermittently writes no
file ("could not get idle state"). tap_label passed that straight to
`cat` and failed the whole smoke on the first miss, while the launch loop
already retried. The beta.16 commit failed four runs in a row this way;
the identical tree passed on its PR. Only the dump is retried — crash,
ANR, service and listener assertions are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:12:23 +00:00
Никита Sonic 9529df3fdf fix(worker): туннели через Worker рвались с 1003 на новых compatibility date; 2.0.0-beta.16 (#63)
* worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required"

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket messages as Blob (websocket_standard_binary_type). The message handler accepts only ArrayBuffer and closes every tunnel with 1003. Setting binaryType before accept() restores ArrayBuffer delivery regardless of the compatibility date.

* fix(worker): accept Blob frames and name an outdated Worker in the log; 2.0.0-beta.16

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket
frames as Blob unless binaryType is "arraybuffer", and the Worker closed
every tunnel with 1003 right after the handshake. wrangler.toml itself pins
2026-09-19, so every fresh deployment was broken (#58, #50, #53).

On top of #61 (binaryType before accept) the Worker now also relays Blob
frames in order, and the client log tells the user to redeploy the script
when a Worker closes with "1003 binary messages required".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com>
Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:37:15 +00:00
Никита Sonic 05ce9566a8 fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
2026-09-19 16:04:39 +03:00
11 changed files with 90 additions and 23 deletions
Generated
+1 -1
View File
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]] [[package]]
name = "tglock" name = "tglock"
version = "2.0.0-beta.15" version = "2.0.0-beta.16"
dependencies = [ dependencies = [
"aes", "aes",
"cipher", "cipher",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tglock" name = "tglock"
version = "2.0.0-beta.15" version = "2.0.0-beta.16"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.88"
description = "Telegram unblock via local WebSocket tunnel" description = "Telegram unblock via local WebSocket tunnel"
+2
View File
@@ -41,6 +41,8 @@
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю. Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
> **Обнови скрипт, если Worker создан или менял compatibility date после 17 марта 2026.** С этой даты Cloudflare отдаёт бинарные WebSocket-кадры как `Blob`, и скрипт до beta.16 закрывал каждый туннель сразу после рукопожатия. В журнале TGLock это строка `WebSocket закрыт: 1003 binary messages required`, в диагностике туннели мигают 0 → 1 → 0. Текущий скрипт выставляет `binaryType = "arraybuffer"` и принимает `Blob` (исправление от [@avost](https://github.com/by-sonic/tglock/pull/61)).
> **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений. > **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений.
Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют. Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.15", "version": "2.0.0-beta.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.15", "version": "2.0.0-beta.16",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"private": true, "private": true,
"version": "2.0.0-beta.15", "version": "2.0.0-beta.16",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js", "test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
+10 -2
View File
@@ -37,7 +37,11 @@ def ui_dump(label):
# Dump first, then read the file; never tap coordinates inferred from a # Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions. # screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml") adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml") status = adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml", check=False)
if not adb("shell", "ls", "/sdcard/tglock-ui.xml", check=False).endswith("tglock-ui.xml"):
# On a loaded emulator UIAutomator intermittently reports "could not get
# idle state" and writes nothing. That is the tool, not the app.
raise subprocess.SubprocessError(f"uiautomator dump failed: {status}")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml") text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8") (EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text) return ET.fromstring(text)
@@ -58,8 +62,12 @@ def label_node(tree, label):
def tap_label(label, stage): def tap_label(label, stage):
for attempt in range(3): for attempt in range(5):
try:
point = label_node(ui_dump(f"{stage}-{attempt}"), label) point = label_node(ui_dump(f"{stage}-{attempt}"), label)
except (subprocess.SubprocessError, ET.ParseError) as error:
(EVIDENCE / f"{stage}-{attempt}.error.txt").write_text(str(error), encoding="utf-8")
point = None
if point is not None: if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1])) adb("shell", "input", "tap", str(point[0]), str(point[1]))
return return
+30 -3
View File
@@ -795,9 +795,7 @@ async fn ws_tunnel(
}); });
if !received_payload || transport_error { if !received_payload || transport_error {
let reason = match frame { let reason = match frame {
Some(frame) => { Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
format!("WebSocket закрыт: {} {}", frame.code, frame.reason)
}
None => "WebSocket закрыт без ответа Telegram".to_owned(), None => "WebSocket закрыт без ответа Telegram".to_owned(),
}; };
return Err(tunnel_route_error(stats, &connected, dc, &reason)); return Err(tunnel_route_error(stats, &connected, dc, &reason));
@@ -938,6 +936,24 @@ async fn cdn_tcp_tunnel(
} }
} }
/// Текст для журнала, когда WebSocket закрыла другая сторона.
///
/// `1003 binary messages required` отвечает только старый скрипт Worker'а: с
/// compatibility date 2026-03-17 Cloudflare отдаёт бинарные кадры как Blob, и
/// такой скрипт рвал каждый туннель сразу после рукопожатия (by-sonic/tglock#61).
/// Обновление TGLock код в чужом Cloudflare-аккаунте не меняет, поэтому
/// пользователю надо сказать прямо, что делать.
fn describe_ws_close(code: u16, reason: &str) -> String {
let mut text = format!("WebSocket закрыт: {code} {reason}");
if code == 1003 && reason.contains("binary messages required") {
text.push_str(
" — скрипт Cloudflare Worker устарел: опубликуйте заново \
worker/tglock-worker.js из репозитория",
);
}
text
}
fn tunnel_route_error( fn tunnel_route_error(
stats: &Stats, stats: &Stats,
connected: &crate::transport::ConnectedRoute, connected: &crate::transport::ConnectedRoute,
@@ -969,6 +985,17 @@ mod tests {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
#[test]
fn an_outdated_worker_close_says_to_redeploy_it() {
let outdated = describe_ws_close(1003, "binary messages required");
assert!(outdated.starts_with("WebSocket закрыт: 1003 binary messages required"));
assert!(outdated.contains("worker/tglock-worker.js"));
assert_eq!(
describe_ws_close(1011, "Telegram socket failed"),
"WebSocket закрыт: 1011 Telegram socket failed"
);
}
/// Start the proxy on a kernel-assigned port. /// Start the proxy on a kernel-assigned port.
/// ///
/// Binding first and reading the port back removes the reserve-then-rebind /// Binding first and reading the port back removes the reserve-then-rebind
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "TGLock", "productName": "TGLock",
"version": "2.0.0-beta.15", "version": "2.0.0-beta.16",
"identifier": "com.bysonic.tglock", "identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock", "mainBinaryName": "tglock",
"build": { "build": {
+1 -1
View File
@@ -451,7 +451,7 @@ async function copyDiagnostics(): Promise<void> {
// Public reports exclude proxy links, worker domains and free-form logs. // Public reports exclude proxy links, worker domains and free-form logs.
const report = { const report = {
application: "TGLock", application: "TGLock",
version: "2.0.0-beta.15", version: "2.0.0-beta.16",
running: status.running, running: status.running,
activeConnections: status.activeConnections, activeConnections: status.activeConnections,
tunnels: status.tunnels, tunnels: status.tunnels,
+17 -8
View File
@@ -85,6 +85,7 @@ export default {
} }
const [client, server] = Object.values(new WebSocketPair()); const [client, server] = Object.values(new WebSocketPair());
server.binaryType = "arraybuffer";
server.accept(); server.accept();
const writer = upstream.writable.getWriter(); const writer = upstream.writable.getWriter();
@@ -116,28 +117,36 @@ export default {
server.addEventListener("message", (event) => { server.addEventListener("message", (event) => {
if (closed) return; if (closed) return;
if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) { const data = event.data;
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
// binaryType не "arraybuffer". Свойство выставлено выше; Blob всё равно
// принимается — на случай рантайма, который его не учитывает. Раньше такой
// кадр закрывал туннель кодом 1003 сразу после рукопожатия.
const blob = typeof Blob !== "undefined" && data instanceof Blob;
if (!blob && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
shutdown(1003, "binary messages required"); shutdown(1003, "binary messages required");
return; return;
} }
const chunk = event.data instanceof ArrayBuffer const chunk = blob ? null : data instanceof ArrayBuffer
? new Uint8Array(event.data) ? new Uint8Array(data)
: new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (pendingBytes + chunk.byteLength > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) { const size = blob ? data.size : chunk.byteLength;
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full"); shutdown(1009, "Telegram write queue full");
return; return;
} }
pendingBytes += chunk.byteLength; pendingBytes += size;
pendingMessages += 1; pendingMessages += 1;
pending = pending pending = pending
.then(async () => { .then(async () => {
if (closed) return; if (closed) return;
const bytes = chunk || new Uint8Array(await data.arrayBuffer());
await writer.ready; await writer.ready;
if (!closed) await writer.write(chunk); if (!closed) await writer.write(bytes);
}) })
.catch(() => shutdown(1011, "Telegram write failed")) .catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => { .finally(() => {
pendingBytes -= chunk.byteLength; pendingBytes -= size;
pendingMessages -= 1; pendingMessages -= 1;
}); });
}); });
+23 -2
View File
@@ -22,7 +22,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
const events = new Map(); const events = new Map();
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] }; const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] };
const server = { const server = {
accept() { state.accepted = true; }, accept() { state.accepted = true; state.binaryTypeOnAccept = this.binaryType; },
addEventListener(name, callback) { events.set(name, callback); }, addEventListener(name, callback) { events.set(name, callback); },
close(code, reason) { state.closes.push({ code, reason }); }, close(code, reason) { state.closes.push({ code, reason }); },
send(chunk) { state.sent.push(Array.from(chunk)); }, send(chunk) { state.sent.push(Array.from(chunk)); },
@@ -41,7 +41,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
}, },
}; };
const context = vm.createContext({ const context = vm.createContext({
URL, Uint8Array, ArrayBuffer, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout, URL, Uint8Array, ArrayBuffer, Blob, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
Response: class { Response: class {
constructor(body, options) { this.body = body; Object.assign(this, options); } constructor(body, options) { this.body = body; Object.assign(this, options); }
}, },
@@ -133,6 +133,27 @@ test("serialize binary writes, preserve bytes and view offsets", async () => {
f.event("close"); f.event("close");
}); });
test("binary frames arrive as ArrayBuffer on new compatibility dates", async () => {
// With compatibility date 2026-03-17+ Cloudflare delivers Blob unless
// binaryType is set before accept(); the Worker then closed every tunnel
// with 1003 right after the handshake (#61).
const f = await fixture();
await f.fetch();
assert.equal(f.state.binaryTypeOnAccept, "arraybuffer");
f.event("close");
});
test("Blob frames are still relayed in order, never closed as text", async () => {
const f = await fixture();
await f.fetch();
f.message(new Blob([new Uint8Array([7, 8])]));
f.message(new Uint8Array([9]).buffer);
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.writes, [[7, 8], [9]]);
assert.deepEqual(f.state.closes, []);
f.event("close");
});
test("overflow closes socket and discards queued writes", async () => { test("overflow closes socket and discards queued writes", async () => {
const f = await fixture(); const f = await fixture();
await f.fetch(); await f.fetch();