WebDroid'de iki sekmenin birbirini araması
WebDroid'de iki tarayıcı sekmesi iki ayrı Android telefona dönüşüyor, birbirine mesaj atıyor ve sesli arama yapıyor. Sekmeler arasında neyin taşındığını, aramanın nasıl kurulduğunu ve bu düzenin nerede çalışmayı bıraktığını kodun üzerinden anlatıyorum.
WebDroid tarayıcıda çalışan bir Android 15. Görünüşünü Android'e yakın tuttum ama bu projede asıl istediğim şuydu: iki sekme aç, biri öbürünü arasın, karşıda telefon çalsın, cevaplayınca iki taraf birbirini duysun. Mesajlar da öyle: gönderilsin, teslim edilsin, karşı taraf okuyunca gönderen bunu görsün.
Aynı tarayıcıdaki sekmeleri konuşturmanın en kısa yolu BroadcastChannel. WebDroid onu kullanmıyor, kodda storage olayı da yok. Sekmeler birbirini hiç görmüyor: ikisi de aynı Bun sunucusuna bağlanıyor ve aralarındaki her şey oradan geçiyor. Uygulamaların birbirine intent'lerle seslendiği çekirdeği başka bir yazıda öbür projelerle karşılaştırmıştım. Burada o intent'lerin cihazdan cihaza nasıl gittiğine bakıyorum.
Her sekme kendi kimliğiyle açılıyor
İki sekmenin iki telefon olması için önce iki ayrı kimlik gerekiyor. Aynı tarayıcıdaki sekmeler aynı localStorage'ı paylaştığı için cihaz kimliği yalnızca oradan okunsaydı ikinci sekme ilk telefonun kopyası olurdu. Bu yüzden src/kernel/storage.ts önce adrese bakıyor:
// The URL wins so a second tab can be a different phone (?device=b); otherwise this browser's device is reused.
function resolveDeviceId(): string {
const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get("device");
if (fromUrl && DEVICE_ID.test(fromUrl)) return fromUrl;
try {
const saved = localStorage.getItem("webdroid.device");
if (saved && DEVICE_ID.test(saved)) return saved;
const created = crypto.randomUUID().slice(0, 8);
localStorage.setItem("webdroid.device", created);
return created;
} catch {
return "default";
}
}Telefonun yanındaki araç çubuğunun en alttaki düğmesi, rastgele sekiz karakterlik bir kimlikle ?device=... adresini yeni bir sekmede açıyor (src/system/DeviceToolbar.tsx). Adresi elle de yazabilirsin, ?device=b yeterli. Sunucu her yeni kimliğe sırayla bir numara veriyor: ilk cihaz 5550100, sonraki 5550101. Numara cihaz tablosundaki satır sayısından hesaplanıyor. Kimlik ^[a-z0-9-]{1,40}$ kalıbına uymuyorsa API istekleri SQL'e ulaşmadan 400 dönüyor.
Tarayıcının localStorage'ına yazılan tek şey bu kimlik. Telefonun geri kalan bütün verisi sunucuda duruyor.
Sekmelerin arasında bir sunucu var
Her cihaz açılışta /ws?device=... adresine bir WebSocket açıyor. Sunucu bağlantıyı yükseltirken cihazın numarasını bulup bağlantıya iliştiriyor ve onu dev:<numara> adlı bir konuya abone ediyor. İşin çoğunu Bun'ın yerleşik pub/sub'ı yapıyor: mesaj alıcıya server.publish ile iletiliyor, alıcının bağlı olup olmadığı server.subscriberCount ile öğreniliyor. Gönderen from alanını kendisi dolduramıyor, onu sunucu bağlantının numarasından yazıyor.
İstemci tarafında src/kernel/push.ts her mesajı rastgele bir ref ile gönderiyor, sunucu da aynı ref ile bir onay dönüyor:
ws.onmessage = event => {
const message = JSON.parse(String(event.data)) as { ack?: string; ok?: boolean; from?: string; intent?: Intent };
if (message.ack !== undefined) {
acks.get(message.ack)?.(!!message.ok);
acks.delete(message.ack);
} else if (message.from && message.intent) receive(message.from, message.intent);
};
// ...
export function send(to: string, intent: Intent, queue = false): Promise<boolean> {
const ws = socket;
if (!ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false);
const ref = crypto.randomUUID();
return new Promise(resolve => {
acks.set(ref, resolve);
ws.send(JSON.stringify({ to: to.replace(/\D/g, ""), ref, queue, intent }));
setTimeout(() => acks.delete(ref) && resolve(false), 5000);
});
}send bir Promise<boolean> dönüyor. Soket açık değilse sonuç hemen false, 5 saniye içinde onay gelmezse yine false. Bağlantı koparsa 3 saniye sonra yeniden kuruluyor.
Uçak modunu açtığında ya da Wi-Fi'yi kapattığında soket gerçekten kapanıyor. Bunu ayarlar store'una abone olan tek bir satır yapıyor, open() de yeniden bağlanmadan önce isOnline()'a bakıyor. Öbür telefon için bu cihaz artık ulaşılamaz durumda.
Sunucudan geçmenin bir yan etkisi var: iki telefonun aynı tarayıcıda olması gerekmiyor. Farklı tarayıcılar, hatta farklı makineler aynı sunucuya bağlı olduğu sürece mesajlaşabiliyor (aramalarda durum başka). Başka bir makineden bağlanmanın bir şartı var, ona en sonda döneceğim.
Mesajlar kuyrukta bekleyebiliyor
Mesajlar uygulaması SMS'leri queue bayrağı açık olarak gönderiyor. Alıcı bağlı değilse ama sunucu o numarayı tanıyorsa mesaj outbox tablosuna yazılıyor ve gönderen yine olumlu onay alıyor. Alıcı bağlandığı anda sunucu bekleyen her şeyi sırayla gönderip tabloyu boşaltıyor. Tanınmayan bir numaraya giden mesaj unknown hatasıyla geri dönüyor.
Gönderilen her mesajın bir durumu var: beklemede, gönderildi, teslim edildi, okundu ya da başarısız. Alıcı mesajı alınca SMS_DELIVERED gönderiyor, konuşma o an ekranda açıksa hemen ardından SMS_READ. Bu onaylar karışık sırayla gelebildiği için src/apps/messages.tsx durumun yalnızca ileri gitmesine izin veriyor:
const RANK: Record<Status, number> = { failed: 0, pending: 1, sent: 2, delivered: 3, read: 4 };
// ...
// Receipts can arrive out of order, so a status only ever moves forward (except retries).
function setStatus(number: string, ids: string[], status: Status): void {
useMessages.setState(({ threads }) => ({
threads: { ...threads, [number]: (threads[number] ?? []).map(item => (ids.includes(item.id) && (RANK[status] > RANK[item.status] || status === "failed") ? { ...item, status } : item)) },
}));
}
async function deliver(os: Os, number: string, message: Message): Promise<void> {
const ok = await os.send(number, { action: "SMS", extras: { id: message.id, text: message.text } }, true);
if (ok) setStatus(number, [message.id], "sent");
else setStatus(number, [message.id], isOnline() ? "failed" : "pending");
}Çevrim dışıyken yazdığın mesaj pending olarak kalıyor. Bağlantı geri gelince usePhone store'unu dinleyen bir abone, bekleyen mesajları tek tek yeniden deliver'dan geçiriyor. Çevrim içiyken başarısız olan mesaj ise failed oluyor ve ekranda soluk görünüyor.
Bir arama, sekiz intent
Sesli aramalar WebRTC ile yapılıyor, sinyal için ayrı bir sunucu yazmadım. src/kernel/telecom.ts aynı push kanalını kullanıyor ve app: "telecom" taşıyan sekiz intent gönderiyor: OFFER, RINGING, ANSWER, DECLINE, CANCEL, END, BUSY ve HOLD. os.ts'teki push dinleyicisi telecom intent'lerini doğrudan handleSignal'a veriyor, geri kalanları kurulu uygulamalara dağıtıyor. Gelen sinyalleri karşılayan fonksiyon, src/kernel/telecom.ts içindeki handleSignal:
export function handleSignal(from: string, intent: Intent): void {
const { status, number } = useCall.getState();
if (intent.action === "OFFER") {
if (status !== "idle" && status !== "ended") return void send(from, { app: "telecom", action: "BUSY" });
pendingOffer = String(intent.extras?.sdp ?? "");
useCall.setState({ ...IDLE, status: "incoming", number: from });
void signal("RINGING");
// ...
timer = setTimeout(() => end("missed"), 30000);
return;
}
if (from !== number) return;
if (intent.action === "RINGING" && status === "dialing") useCall.setState({ status: "ringing" });
if (intent.action === "ANSWER" && peer) {
clearTimeout(timer);
void peer.setRemoteDescription({ type: "answer", sdp: String(intent.extras?.sdp ?? "") });
useCall.setState({ status: "active", startedAt: Date.now() });
}
if (intent.action === "HOLD") useCall.setState({ remoteHeld: !!intent.extras?.held });
const endings: Record<string, EndReason> = { BUSY: "busy", DECLINE: "declined", CANCEL: "missed", END: "ended" };
if (endings[intent.action]) end(endings[intent.action]);
}Arayan taraf dial ile bir offer üretip OFFER gönderiyor. Bu sinyal kuyruğa alınmıyor: karşı taraf bağlı değilse onay false geliyor ve arama hemen "ulaşılamıyor" diye bitiyor. Aranan taraf zaten bir aramadaysa BUSY dönüyor. Değilse offer'ı saklıyor, RINGING gönderiyor ve tam ekran bir gelen arama bildirimi açıyor. İki tarafta da 30 saniyelik bir sayaç var. Süre dolunca arayan taraf CANCEL gönderiyor, aranan tarafta da arama cevapsız düşüyor ve bir cevapsız arama bildirimi çıkıyor.
from !== number satırı, görüşme sürerken üçüncü bir cihazdan gelen sinyalleri yok sayıyor. İşlenen tek istisna OFFER, onun cevabı da BUSY. Aramayı beklemeye aldığında yerel mikrofon izi kapanıyor, karşıdan gelen ses susuyor ve karşıya HOLD gidiyor. Hoparlör düğmesi ise biraz hile: masaüstünde tek bir ses çıkışı olduğu için yalnızca sesi 0,4 ile 1 arasında değiştiriyor, kodun yorumu da bunu açıkça söylüyor.
ICE adaylarını tek tek göndermemek
WebRTC'de bağlantı adayları genelde bulundukça tek tek gönderilir, buna trickle ICE deniyor. Ben bu yolu seçmedim. describe yerel açıklamayı ayarlıyor, aday toplama bitene kadar bekliyor ve bütün adayları içeren SDP'yi tek mesajda yolluyor. Gerekçesi src/kernel/telecom.ts'te fonksiyonun üstündeki yorumda yazılı:
function createPeer(stream: MediaStream): RTCPeerConnection {
const connection = new RTCPeerConnection();
for (const track of stream.getTracks()) connection.addTrack(track, stream);
// ...
connection.onconnectionstatechange = () => connection.connectionState === "failed" && end("ended");
return connection;
}
// Signalling sends one complete SDP instead of trickling candidates; host candidates gather in milliseconds.
async function describe(connection: RTCPeerConnection, description: RTCSessionDescriptionInit): Promise<string> {
await connection.setLocalDescription(description);
if (connection.iceGatheringState !== "complete") {
await new Promise<void>(resolve => {
connection.addEventListener("icegatheringstatechange", () => connection.iceGatheringState === "complete" && resolve());
setTimeout(resolve, 1500);
});
}
return connection.localDescription?.sdp ?? "";
}Yerel adaylar milisaniyeler içinde toplanıyor. Yine de bir şey takılırsa 1,5 saniye sonra eldeki SDP ile devam ediliyor. Karşılığında sinyal protokolü sade kalıyor: bir OFFER, bir ANSWER, aralarında aday mesajı yok. Uzak açıklamadan önce gelen adayları bir yerde biriktirme derdi de ortadan kalkıyor.
Bu düzenin en büyük sınırı createPeer'ın ilk satırında. RTCPeerConnection hiçbir ayar almadan kuruluyor, ICE sunucusu listesi boş. STUN olmadığı için tarayıcı dışarıdan görünen adresini öğrenemiyor, TURN da olmadığı için araya bir aktarıcı giremiyor. Elde yalnızca makinenin kendi ağ arayüzlerinin adresleri kalıyor. Aynı bilgisayardaki iki sekme ya da aynı yerel ağdaki iki makine birbirine bu adreslerle ulaşabiliyor, farklı ağlardaki iki kişi ise ulaşamıyor. O durumda arama cevaplanmış görünüyor ama ses akmıyor, bağlantı failed durumuna düşünce de görüşme kapanıyor. Mesajlarda böyle bir sınır yok, onlar sunucudan geçiyor.
Bütün telefonlar tek bir SQLite dosyasında
Telefonların verisi sunucuda duruyor: src/server.ts açılışta bun:sqlite ile data/webdroid.sqlite dosyasını açıyor, WAL kipine alıyor ve dört tablo kuruyor:
const db = new Database(options.dbPath ?? "data/webdroid.sqlite", { create: true, strict: true });
db.run("PRAGMA journal_mode = WAL");
db.run("CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, number TEXT UNIQUE)");
db.run("CREATE TABLE IF NOT EXISTS kv (device TEXT, key TEXT, value TEXT, PRIMARY KEY (device, key))");
db.run("CREATE TABLE IF NOT EXISTS files (device TEXT, path TEXT, mime TEXT, size INTEGER, mtime INTEGER, data BLOB, PRIMARY KEY (device, path))");
db.run("CREATE TABLE IF NOT EXISTS outbox (id INTEGER PRIMARY KEY, number TEXT, body TEXT)");
const numberOf = (device: string): string => {
const found = db.query<{ number: string }, [string]>("SELECT number FROM devices WHERE id = ?").get(device);
if (found) return found.number;
const count = db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM devices").get()?.n ?? 0;
const number = String(FIRST_NUMBER + count);
db.query("INSERT INTO devices (id, number) VALUES (?, ?)").run(device, number);
return number;
};kv her cihazın zustand store'larını tutuyor: anahtar store'un adı, değer de JSON. files galerideki fotoğrafları, ses kayıtlarını ve dosya yöneticisindeki her şeyi BLOB olarak saklıyor. Cihazlar birbirinden yalnızca device sütunuyla ayrılıyor. Ses ve video dosyalarını ileri sarabilmen için dosya ucu Range isteklerine de cevap veriyor.
İstemcide src/kernel/storage.ts, zustand'ın persist ara katmanına kendi depolama adaptörünü veriyor. Okumalar bellekteki bir önbellekten geliyor. O önbellek açılışta tek bir GET /api/kv/<cihaz> isteğiyle doluyor, sonra bütün store'lar oradan yükleniyor. Yazmalar 400 ms bekletilip birleştiriliyor, çünkü store'lar not otomatik kaydı gibi yerlerde her tuş vuruşunda kaydediyor. Başarısız bir yazma, aynı anahtar için kuyrukta daha yeni bir değer yoksa 3 saniye sonra yeniden deneniyor. Sayfa kapanırken bekleyen yazmalar keepalive ile gönderiliyor.
İki sekme senaryosunda bu düzenin faydası açık: veriler localStorage'da olsaydı aynı tarayıcıdaki iki telefon aynı depoyu paylaşırdı. Burada ise her telefonun notları, rehberi ve arama geçmişi kendi device satırlarında duruyor.
Eksik kalanlar
Deponun tek bir commit'i var ve başlığı şu: "Remove obsolete test files and setup for various modules including calculator, home layout, kernel, server, and telecom functionalities." Yani telecom için de testler vardı. Artık depoda yoklar ama izleri duruyor: README hâlâ bun test diyor, createServer'ın başka bir veritabanı yolu alan dbPath seçeneği yerinde, bunfig.toml da olmayan bir dosyayı yüklemeye çalışıyor:
[serve.static]
plugins = ["bun-plugin-tailwind"]
[test]
preload = ["./test/setup.ts"]package.json'daki lint betiği de depoda olmayan scripts/lint.ts'i çağırıyor. İlk iş bunları ya geri getirmek ya da temizlemek.
Arama tarafında STUN eklemek tek satırlık bir ayar ama yalnız başına yetmez. describe'daki "yerel adaylar milisaniyede toplanır" varsayımı STUN ile geçersiz kalıyor, çünkü dış adresi öğrenmek bir ağ turu istiyor. O noktada ya 1,5 saniyelik sınırı büyütmek ya da adayları bulundukça göndermek (trickle ICE) gerekecek. Sıkı NAT arkasındaki kullanıcılar için de TURN gerekiyor.
Yerel ağ da göründüğü kadar kolay değil. Tarayıcı mikrofonu (getUserMedia) yalnızca güvenli bağlamda veriyor. localhost güvenli sayıldığı için aynı makinede sorun yok. Başka bir makineden düz http ile bağlanırsan mikrofon gelmiyor, yani LAN'da arama için sunucunun HTTPS arkasında olması gerekiyor. hashSecret'taki yorum, düz http ile LAN'dan açılma durumunu hesaba katıyor ve o durumda crypto.subtle yerine FNV-1a'ya düşüyor. Ama crypto.randomUUID de yalnızca güvenli bağlamda var ve Shell onu ilk render'da çağırıyor. Yani düz http'de bu çağrının ilk render'da hata vermesi beklenir. Bu yolu henüz denemedim.
İki küçük eksik daha var. SMS_DELIVERED ve SMS_READ kuyruğa alınmadan gönderiliyor. O an gönderen bağlı değilse onay kayboluyor ve mesaj sent olarak kalıyor. Kimlik doğrulama da yok: bir cihaz kimliğini bilen herkes o telefonun verisini okuyabiliyor, onun numarasıyla konuşabiliyor. Kod genel olarak yerelde çalışan bir simülatör varsayıyor, proxy'deki DNS rebinding yorumu da bunu açıkça yazıyor. Herkese açık bir sunucuya koymadan önce ilk halletmem gereken şey kimlik doğrulama.
- Proje sayfası
- Kaynak kod;
bun installvebun devilehttp://localhost:4321adresinde açabilirsin.
