diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d9bd9c2..b168b22 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from 'node:child_process' +import { execFile, execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' @@ -32,6 +32,7 @@ import { shell, systemPreferences } from 'electron' +import { WebSocket as RelayWebSocket } from 'ws' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' @@ -17866,16 +17867,33 @@ function getMobileLink(): MobileLink { return { baseUrl: connection.baseUrl, token: connection.token } }, isRemoteMode: mobileLinkRemoteMode, - firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }), + firewall: createWindowsFirewall({ + spawn, + platform: process.platform, + env: process.env, + // Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the + // console code page; latin1 keeps every byte and the parser needs no translated word. + run: (file, args) => + new Promise(resolve => { + execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => { + const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0 + + resolve({ code, stdout: String(stdout ?? '') }) + }) + }) + }), fetch: (url, init) => fetch(url, init as RequestInit), + // Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy. + WebSocket: RelayWebSocket, addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()), version: app.getVersion(), pcName: os.hostname(), platform: process.platform, // Method + path + status only (no token, code, query or body). Successful - // API traffic is not logged; pairing and refusals are, for diagnosis. + // API traffic is not logged; pairing and refusals are, for diagnosis. Relay + // lines are status changes only ("relay connected", "relay failed:"). log: line => { - if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { + if (/^relay /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { rememberLog(`[mobile-lan] ${line}`) } } diff --git a/apps/desktop/electron/mobile-host-ui.mjs b/apps/desktop/electron/mobile-host-ui.mjs index c039b31..3586f78 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -6,56 +6,72 @@ import { drawQr } from './mobile/qr.mjs' const TEXT = { tr: { eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı', - intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.', - enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı', + intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.', + toggle: 'Telefon bağlantısını kullan', remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.', - firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', - firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', - firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', - retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç', + relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı', + relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)', + replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' }, + lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)', + lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)', + lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat', + reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.', + addPhone: 'Telefon ekle', cancel: 'Vazgeç', scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.', orCode: 'ya da telefonda şu kodu yazın:', codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.', codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', - sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.', + securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`, + anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.', countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.', devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.', added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır', removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`, - footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.', + removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.', + renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.', + renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?', errors: { remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.', vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.', not_enabled: 'Önce telefon bağlantısını açın.', - no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.', + no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.', + relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.', port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).', failed: 'İşlem tamamlanamadı. Yeniden deneyin.', }, }, en: { eyebrow: 'AITURK · COMPUTER', title: 'Phone connection', - intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.', - enabled: 'Phone connection is on', disabled: 'Phone connection is off', + intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.', + toggle: 'Use phone connection', remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.', - firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.', - firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.', - firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.', - retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel', + relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi', + relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)', + replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' }, + lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)', + lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)', + lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off', + reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.', + addPhone: 'Add a phone', cancel: 'Cancel', scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.', orCode: 'or type this code on the phone:', codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.', codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', - sameWifi: 'The phone and the computer must be on the same Wi-Fi network.', + securityCode: code => `Security code: ${code} (the phone must show the same code)`, + anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.', countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.', devices: 'Connected phones', noDevices: 'No phone is connected yet.', added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove', removed: name => `${name} was removed.`, paired: name => `${name} connected.`, - footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.', + removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.', + renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.', + renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?', errors: { remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.', vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.', not_enabled: 'Turn phone connection on first.', - no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).', + no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.', + relay_unavailable: 'The internet cannot be reached, try again.', port_unavailable: 'No free port was found (47321–47340 are in use).', failed: 'That did not work. Try again.', }, @@ -66,7 +82,8 @@ const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementByI const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' }) document.documentElement.lang = lang; document.title = `AITURK · ${t.title}` for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'], - ['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'], + ['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'], + ['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'], ['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key] let current = null, drawnQr = '', busy = false @@ -83,10 +100,20 @@ function tick() { function render(state) { current = state - $('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled + $('enabled').checked = state.enabled $('remote-box').hidden = !state.remote - const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall] - $('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || '' + // Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried. + const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' } + $('status-box').hidden = !state.enabled || state.remote + const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status + $('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status + $('reissued-box').hidden = !(state.enabled && relay.reissued) + const noRule = lan.direct && lan.firewall === 'missing' + $('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle + : state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff + $('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off' + $('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow + $('lan-disallow').hidden = !lan.direct $('pair-section').hidden = !state.enabled || state.remote $('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing if (state.pairing) { @@ -94,6 +121,8 @@ function render(state) { $('pair-qr').setAttribute('aria-label', t.scan) $('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}` $('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath] + $('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : '' + $('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi tick() } else { drawnQr = ''; $('pair-code').textContent = '' } $('devices-section').hidden = !state.enabled && state.devices.length === 0 @@ -109,9 +138,9 @@ function render(state) { label.append(name, added, seen); row.append(label, button) return row })) - $('footer').hidden = state.port === null - $('footer-info').textContent = state.port === null ? '' : t.footer(state.port) - $('firewall-remove').hidden = state.firewall !== 'ok' + $('footer').hidden = !state.enabled || state.remote + $('relay-renew').hidden = !relay.ready + $('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed' } async function run(work) { @@ -126,7 +155,9 @@ async function refresh() { try { render(await host.call('state')) } catch (error $('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) } $('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) }) $('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) }) -$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) }) +$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) }) +$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) }) +$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) } $('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) }) host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) }) setInterval(tick, 1000) diff --git a/apps/desktop/electron/mobile-host.css b/apps/desktop/electron/mobile-host.css index 58b2959..6b9d6f4 100644 --- a/apps/desktop/electron/mobile-host.css +++ b/apps/desktop/electron/mobile-host.css @@ -14,3 +14,13 @@ .device button { flex:none; margin:0 } footer { margin-top:34px } button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline } +.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 } +.status-row strong { color:var(--text); overflow-wrap:anywhere } +.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) } +.status-row strong[data-state=connected]::before { background:var(--accent) } +.status-row strong[data-state=failed]::before { background:var(--danger) } +.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 } +.status-actions button { margin:0; min-height:44px } +.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere } +footer { display:flex; flex-wrap:wrap; gap:8px 20px } +footer button.link { min-height:44px } diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index 5194367..6ea6abf 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -6,7 +6,12 @@ - + +