feat(aiturk): phone link for AITURK Mobil (1.0.0-beta.15)

Settings gets a Phone link window: when enabled, the IDE main process runs a
small TLS proxy on the local network (self-signed certificate, pinned by its
fingerprint) in front of the loopback backend and pairs a phone by QR code
or an 8-character code. Each phone gets its own revocable token; the backend
session token never leaves the computer. The root page, terminal sockets and
secret or settings-changing endpoints are not reachable from a phone. The
old WebRTC pairing window is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-04 06:02:02 +03:00
1 parent 25d8c18c10
commit 03d3803dfc
16 files changed
+3351 -275

No files matched your search

+88 -8
View File
@@ -9,6 +9,7 @@ import tls from 'node:tls'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { startChromeBridge } from './chrome-bridge.mjs'
import { createMobileWindow } from './mobile-host.mjs'
import { MobileVault } from './mobile-secrets.mjs'
import {
app,
@@ -34,6 +35,14 @@ import {
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
import {
createMobileLink,
createMobileLinkFileStore,
createWindowsFirewall,
defaultRouteAddress,
type MobileLink,
selectLanAddresses
} from './mobile-lan-proxy'
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
import { appIconCandidates, resolveAppIcon } from './app-icon'
@@ -17818,19 +17827,90 @@ function openMobileConnectionWindow() {
return
}
mobileConnectionWindow = createMobileWindow({
directory: path.join(app.getPath('userData'), 'aiturk-mobile'),
assets: path.dirname(fileURLToPath(import.meta.url)),
resolveBackend: async () => {
const profile = primaryProfileKey()
if (globalRemoteActive() || profileHasRemoteOverride(profile)) throw new Error('Mobil bağlantı için yerel IDE bağlantısını seçin.')
return ensureBackend(profile)
}
})
assets: path.dirname(fileURLToPath(import.meta.url)),
link: getMobileLink()
})
}
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
// the loopback backend + pairing. One controller for the whole app run; it only
// listens while the feature is on and a phone is paired or being paired.
let mobileLink: MobileLink | null = null
function mobileLinkRemoteMode() {
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
}
function getMobileLink(): MobileLink {
if (mobileLink) {
return mobileLink
}
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
mobileLink = createMobileLink({
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
vault: new MobileVault(directory, safeStorage),
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
resolveBackend: async () => {
if (mobileLinkRemoteMode()) {
throw new Error('aiturk-mobile:remote_mode')
}
const connection = await ensureBackend(primaryProfileKey())
if (connection.mode !== 'local') {
throw new Error('aiturk-mobile:remote_mode')
}
return { baseUrl: connection.baseUrl, token: connection.token }
},
isRemoteMode: mobileLinkRemoteMode,
firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }),
fetch: (url, init) => fetch(url, init as RequestInit),
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.
log: line => {
if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
rememberLog(`[mobile-lan] ${line}`)
}
}
})
return mobileLink
}
// Only the primary window's top-level frame may open the phone window.
ipcMain.handle('aiturk:mobile:open', async event => {
assertAiturkLoginSender(event)
if (process.platform !== 'win32') {
throw new Error('aiturk-mobile:unsupported')
}
openMobileConnectionWindow()
return { ok: true }
})
app.on('will-quit', () => {
void mobileLink?.stop().catch(() => undefined)
})
app.whenReady().then(() => {
// Explicit development/beta entry point; never enables remote access on boot.
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
// Resume the phone link only when the user turned it on earlier AND a phone
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
if (process.platform === 'win32') {
void getMobileLink()
.start()
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
}
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
void startChromeBridge({
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
+132 -91
View File
@@ -1,94 +1,135 @@
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
import { drawQr, InviteScanner } from './mobile/qr.mjs'
import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.mjs'
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
const status = text => { $('status').textContent = text }
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
const identityScanner = new InviteScanner(document.createElement('video'), async raw => {
if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.')
const value = JSON.parse(raw)
const pairingId = $('pairing').value.trim() || value.pairingId
parsePhoneIdentity(raw, pairingId)
if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.')
$('pairing').value = pairingId; $('phone-identity').value = raw
status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.')
})
$('phone-identity-file').onchange = async event => {
try { await identityScanner.image(event.target.files[0]) }
catch (error) { status(error.message) }
finally { event.target.value = '' }
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
/* global window, document */
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ı',
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ç',
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ı.',
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ı.',
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.',
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',
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',
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.',
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.',
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).',
port_unavailable: 'No free port was found (47321–47340 are in use).',
failed: 'That did not work. Try again.',
},
},
}
action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` })
action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') })
action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') })
async function desktopIdentity(create = false) {
const setup = await host.call('setup')
if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.')
if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint)
if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.')
const identity = await createDeviceIdentity(setup.deviceId)
await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint })
return identity
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
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'],
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
let current = null, drawnQr = '', busy = false
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed
function tick() {
const pairing = current?.pairing
if (!pairing) return
const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))
$('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0'))
if (left === 0) { notice(t.expired); void refresh() }
}
action('invite', async () => {
const identity = await desktopIdentity(true), invite = await host.call('invite.create')
drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint }))
$('invite-qr').hidden = false
$('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}`
})
action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) })
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` })
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') })
action('refresh', async () => {
const data = await host.call('devices'); $('devices').replaceChildren()
for (const device of data.devices) {
const row = document.createElement('p'), label = document.createElement('span'), button = document.createElement('button')
label.textContent = `${device.name} · ${device.platform}${device.self ? ' · Bu bilgisayar' : ''}`
button.textContent = 'Cihazı iptal et'
button.onclick = async () => { button.disabled = true; try { await host.call('device.revoke', { deviceId: device.id, version: device.version }); row.remove(); status('Cihaz iptal edildi.') } catch (error) { status(error.message); button.disabled = false } }
row.append(label, button); $('devices').append(row)
}
})
function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) }
host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) })
async function connect(offer) {
assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint)
const ice = await host.call('ice')
const identity = await desktopIdentity()
const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry)
pc.ondatachannel = event => {
const channel = event.channel; entry.channel = channel
if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return }
let pending = 0
channel.onmessage = async event => {
if (typeof event.data !== 'string' || event.data.length > 65000 || ++pending > 8) { close(offer.id); return }
let message
try {
message = JSON.parse(event.data)
const result = await host.call('channel.request', { channelId: offer.id, message })
if (channel.readyState === 'open') sendFrame(channel, { id: message.id, result })
} catch (error) { if (message?.id && channel.readyState === 'open') channel.send(JSON.stringify({ id: message.id, error: error.message })) }
finally { pending-- }
}
channel.onclose = () => close(offer.id)
channel.onopen = () => status('Telefon şifreli veri kanalıyla bağlı.')
}
try {
await pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
await host.call('channel.answer', { channelId: offer.id, sdp: pc.localDescription.sdp })
entry.timer = setInterval(() => { void host.call('channel.heartbeat', { channelId: offer.id }).catch(() => close(offer.id)) }, 10000)
} catch (error) { close(offer.id); throw error }
function render(state) {
current = state
$('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled
$('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 || ''
$('pair-section').hidden = !state.enabled || state.remote
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
if (state.pairing) {
if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr }
$('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]
tick()
} else { drawnQr = ''; $('pair-code').textContent = '' }
$('devices-section').hidden = !state.enabled && state.devices.length === 0
$('devices-empty').hidden = state.devices.length > 0
$('devices').replaceChildren(...state.devices.map(device => {
const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong')
const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button')
row.className = 'device'; name.textContent = device.name
added.textContent = `${t.added}: ${when(device.createdAt)}`
seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}`
button.textContent = t.remove; button.className = 'danger'
button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) })
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'
}
let listening = false
action('listen', async () => {
if (listening) return
listening = true; status('Onaylı telefon bağlantısı bekleniyor.')
while (listening) {
try { const data = await host.call('inbox'); for (const offer of data.channels) if (!peers.has(offer.id)) await connect(offer) }
catch (error) { status(error.message); listening = false; break }
await new Promise(resolve => setTimeout(resolve, 2000))
}
})
window.addEventListener('pagehide', () => { listening = false; for (const id of peers.keys()) close(id) })
async function run(work) {
if (busy) return
busy = true; document.body.setAttribute('aria-busy', 'true')
for (const button of document.querySelectorAll('button,input')) button.disabled = true
try { await work() } catch (error) { notice(errorText(error)); await refresh() }
finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false }
}
async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(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')) })
$('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)
// "Last seen" and the live marker change without an event; keep the list fresh while the window is open.
setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000)
void refresh()
+16
View File
@@ -0,0 +1,16 @@
/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */
.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer }
.switch input { width:26px; height:26px; margin:0; flex:none }
.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 }
.warning p { color:var(--text); margin:4px 0 }
.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all }
.countdown { text-align:center; color:var(--text); font-weight:600 }
.hint { text-align:center }
#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px }
#pair-live p { text-align:center }
.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 }
.device strong { color:var(--text); display:block; overflow-wrap:anywhere }
.device small { display:block }
.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 }
+26 -23
View File
@@ -1,24 +1,27 @@
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'">
<title>AITURK Mobil bağlantısı</title><link rel="stylesheet" href="mobile/style.css"></head><body><main>
<p class="eyebrow">AITURK · BİLGİSAYAR</p><h1>Telefonunu bağla.</h1>
<p>Bu pencere açıkken eşleştirdiğiniz Android telefon seçtiğiniz IDE konuşmasına erişebilir. Google anahtarı Windows güvenli kasasında tutulur.</p>
<p id="status" role="status" aria-live="polite">Bağlantı kapalı.</p>
<button id="login-start">AITURK hesabımla giriş yap</button><p id="login-code"></p><button id="login-check">Girişi kontrol et</button>
<p>Çıkış, mobil bağlantıları kapatır ve bu penceredeki hesap/Google anahtarını Windows kasasından temizler; başlamış IDE işini durdurmaz.</p>
<button id="logout">Mobil oturumdan çık ve kasayı temizle</button>
<details><summary>Gelişmiş: hesap API anahtarı</summary>
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
<p id="code"></p><hr>
<button id="invite">Telefon için QR oluştur</button><canvas id="invite-qr" hidden aria-label="Süreli telefon eşleştirme QR kodu"></canvas>
<p id="invite-status"></p><button id="invite-check">QR ile gelen telefonu getir</button>
<h2>Telefona izin ver</h2><p>Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.</p>
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
<label>Telefondan alınan kimlik belgesi<textarea id="phone-identity" maxlength="700"></textarea></label>
<label>Telefon kimliği QR resmi<input id="phone-identity-file" type="file" accept="image/*"></label>
<p>Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.</p>
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
<h2>Cihazlar</h2><button id="refresh">Cihaz listesini yenile</button><div id="devices"></div>
<p>Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.</p>
</main><script src="mobile/vendor/jsQR.js"></script><script type="module" src="mobile-host-ui.mjs"></script></body></html>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'">
<title>AITURK · Telefon bağlantısı</title><link rel="stylesheet" href="mobile/style.css"><link rel="stylesheet" href="mobile-host.css"></head><body><main>
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</h1>
<p id="t-intro"></p>
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
<section id="firewall-box" class="warning" hidden><p id="firewall-text"></p><button id="firewall-retry" class="secondary"></button></section>
<section id="pair-section" hidden>
<button id="pair-begin"></button>
<div id="pair-live" hidden>
<p id="t-scan"></p>
<canvas id="pair-qr" role="img"></canvas>
<p id="t-or-code"></p>
<p id="pair-code" class="code" aria-live="off"></p>
<p id="pair-code-path"></p>
<p id="pair-countdown" class="countdown"></p>
<p id="t-same-wifi" class="hint"></p>
<button id="pair-cancel" class="secondary"></button>
</div>
</section>
<section id="devices-section" hidden>
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
</section>
<footer id="footer" hidden><small id="footer-info"></small> <button id="firewall-remove" class="link"></button></footer>
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
+34 -146
View File
@@ -1,157 +1,45 @@
// "Telefon bağlantısı" window: a thin IPC shell around the controller built in
// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the
// main process for the whole app run — closing this window only cancels a
// pairing in progress; paired phones stay connected.
import { join } from 'node:path'
import { hostname } from 'node:os'
import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs'
import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs'
import { AccountClient } from '../../mobile/src/transport.mjs'
import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs'
import { BrowserWindow, ipcMain } from 'electron'
export function createMobileWindow({ directory, assets, resolveBackend, show = true }) {
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
const journal = new DeliveryJournal(join(directory, 'mobile', 'deliveries'))
const issuer = new LiveTokenIssuer(vault)
const clients = new Map()
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK Mobil bağlantısı',
const CODED = /^aiturk-mobile:[a-z_]+$/
export function createMobileWindow({ assets, link, show = true }) {
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı',
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
nodeIntegration: false, sandbox: true, backgroundThrottling: false } })
nodeIntegration: false, sandbox: true } })
window.setMenu(null)
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', event => event.preventDefault())
const api = async () => new AccountClient(await vault.load())
const call = async (path, body, method) => (await api()).call(path, body, method)
const send = frame => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', frame) }
const handler = async (event, action, params = {}) => {
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.')
if (action === 'setup') {
const secrets = await vault.load()
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey),
deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint }
}
if (action === 'identity.save') {
const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint)
if (!previous.deviceId || previous.deviceId !== params.deviceId ||
(previous.localFingerprint && previous.localFingerprint !== fingerprint))
throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.')
await vault.save({ ...previous, localFingerprint: fingerprint })
return { saved: true }
}
if (action === 'account.save') {
if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.')
if (clients.size) throw new Error('Hesap değiştirmeden önce mobil bağlantıyı kapatın.')
const previous = await vault.load()
if (previous.accountToken && previous.accountToken !== params.token) throw new Error('Hesabı değiştirmek için önce mevcut cihazı iptal edin ve bağlantı penceresini yeniden açın.')
await vault.save({ ...previous, accountToken: params.token })
return { saved: true }
}
if (action === 'account.login.start') {
const previous = await vault.load()
if (previous.accountToken || clients.size) throw new Error('Kayıtlı hesap var. Önce mevcut cihazı iptal edin.')
const login = await new AccountClient({}).call('/v1/aiturk/mobile-auth/start', {})
if (!/^https:\/\/turkservis\.online\/account\/aiturk\/mobile-login\/[0-9a-f-]{36}$/.test(login.verificationUri)) throw new Error('Geçersiz giriş adresi.')
await vault.save({ ...previous, pendingLogin: login })
await shell.openExternal(login.verificationUri)
return { code: login.userCode }
}
if (action === 'account.login.check') {
const previous = await vault.load()
if (previous.accountToken || clients.size) throw new Error('Mevcut hesap veya bağlantı sessizce değiştirilemez.')
if (!previous.pendingLogin) throw new Error('Önce giriş başlatın.')
const result = await new AccountClient({ accountToken: previous.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
if (result.state !== 'approved') return { approved: false }
delete previous.pendingLogin
await vault.save({ ...previous, accountToken: result.accountToken })
return { approved: true }
}
if (action === 'account.logout') {
const previous = await vault.load(), token = previous.accountToken || previous.pendingLogin?.deviceCode
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
for (const [id, entry] of clients) { entry.client.close(); send({ channelId: id, close: true }) }
clients.clear(); await vault.save({})
return { cleared: true }
}
if (action === 'desktop.pair') {
const result = await call('/v1/aiturk/pairings', { display_name: hostname(), platform: 'windows-x64' })
await vault.save({ ...await vault.load(), deviceId: result.device_id, deviceToken: result.device_token,
pairingId: result.pairing_id })
await shell.openExternal('https://turkservis.online' + result.verification_uri)
return { code: result.user_code, expiresAt: result.expires_at }
}
if (action === 'phone.approve') {
if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.')
const identity = parsePhoneIdentity(params.identity, params.pairingId)
const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId]
if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.')
const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.')
const current = await vault.load()
if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.')
await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } })
return result
}
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {})
if (action === 'invite.check') return call('/v1/aiturk/mobile/invites')
if (action === 'device.revoke') {
if (!/^[0-9a-f-]{36}$/.test(params.deviceId) || !Number.isInteger(params.version)) throw new Error('Geçersiz cihaz.')
const result = await call(`/v1/aiturk/devices/${params.deviceId}/revoke`, { expected_version: params.version })
for (const [id, entry] of clients) if (entry.phoneId === params.deviceId || result.self_revoked) {
entry.client.close(); clients.delete(id); send({ channelId: id, close: true })
}
if (result.self_revoked) await vault.save({})
return result
}
if (action === 'inbox') {
const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load()
return { ...data, channels: data.channels.map(offer => {
const fingerprint = secrets.phoneFingerprints?.[offer.phone_id]
assertRemoteIdentity(offer.sdp, fingerprint)
return { ...offer, trustedPhoneFingerprint: fingerprint }
}) }
}
if (action === 'ice') return call('/v1/aiturk/mobile/ice')
if (action === 'channel.answer') {
const inbox = await call('/v1/aiturk/mobile/channels')
const offer = inbox.channels.find(row => row.id === params.channelId)
if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.')
assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id])
const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {})
const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }),
{ deviceId: offer.phone_id, journal, authorize })
await call(`/v1/aiturk/mobile/channels/${offer.id}/answer`, { sdp: params.sdp })
clients.set(offer.id, { client, phoneId: offer.phone_id, authorize })
return { accepted: true }
}
if (action === 'channel.heartbeat') {
const entry = clients.get(params.channelId)
if (!entry) throw new Error('Bağlantı yok.')
return entry.authorize()
}
if (action === 'channel.close') {
clients.get(params.channelId)?.client.close(); clients.delete(params.channelId)
return { closed: true }
}
if (action === 'channel.request') {
const entry = clients.get(params.channelId)
if (!entry) throw new Error('Bağlantı yok.')
await entry.authorize()
const message = params.message
if (!message || JSON.stringify(message).length > 65000) throw new Error('İstek boyutu geçersiz.')
if (message.action === 'google.key.save') {
const key = message.params?.key
if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{20,256}$/.test(key)) throw new Error('Google anahtarı biçimi geçersiz.')
await vault.save({ ...await vault.load(), googleKey: key })
return { saved: true }
}
if (message.action === 'google.key.delete') {
const secrets = await vault.load(); delete secrets.googleKey; await vault.save(secrets); return { deleted: true }
}
if (message.action === 'voice.token') return issuer.issue(entry.phoneId)
return entry.client.handle(message)
}
throw new Error('Desteklenmeyen mobil işlem.')
const actions = {
state: () => link.state(),
enable: () => link.enable(),
disable: () => link.disable(),
'pair.begin': () => link.pairBegin(),
'pair.cancel': () => link.pairCancel(),
devices: () => link.devices(),
'device.revoke': params => link.deviceRevoke(params?.deviceId),
'firewall.retry': () => link.firewallRetry(),
'firewall.remove': () => link.firewallRemove(),
}
const handler = async (event, action, params) => {
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden')
if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported')
try { return await actions[action](params) } catch (error) {
// Only fixed codes cross to the renderer; never upstream error text.
throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed')
}
}
const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) })
ipcMain.handle('aiturk-mobile', handler)
window.on('closed', () => { for (const entry of clients.values()) entry.client.close(); clients.clear(); ipcMain.removeHandler('aiturk-mobile') })
window.on('closed', () => {
unsubscribe(); ipcMain.removeHandler('aiturk-mobile')
// The code on screen is gone with the window; do not leave pairing open behind it.
void link.pairCancel().catch(() => {})
})
void window.loadFile(join(assets, 'mobile-host.html'))
return window
}
@@ -0,0 +1,104 @@
/**
* Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed
* certificate of the "Telefon bağlantısı" LAN proxy and its vault storage.
*/
import crypto from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
createSelfSignedCertificate,
fingerprintOfPem,
isUsableIdentity,
loadOrCreateLanIdentity
} from './mobile-lan-cert'
function memoryVault(initial: Record<string, any> = {}) {
const state = { value: structuredClone(initial), saves: 0 }
return {
state,
load: async () => structuredClone(state.value),
save: async (value: Record<string, any>) => {
state.value = structuredClone(value)
state.saves++
}
}
}
describe('createSelfSignedCertificate', () => {
it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => {
const identity = createSelfSignedCertificate()
const x509 = new crypto.X509Certificate(identity.cert)
expect(x509.verify(x509.publicKey)).toBe(true)
expect(x509.publicKey.asymmetricKeyType).toBe('ec')
expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true)
expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3)
expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now())
})
it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => {
const identity = createSelfSignedCertificate()
const der = new crypto.X509Certificate(identity.cert).raw
expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url'))
expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint)
expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint)
})
it('refuses a validity that UTCTime cannot carry', () => {
expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow(
'mobile-lan-cert:validity-out-of-range'
)
})
})
describe('loadOrCreateLanIdentity', () => {
it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => {
const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } })
const first = await loadOrCreateLanIdentity(vault)
expect(first.created).toBe(true)
expect(vault.state.value.accountToken).toBe('old-field')
expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' })
expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY')
const second = await loadOrCreateLanIdentity(vault)
expect(second.created).toBe(false)
expect(second.fingerprint).toBe(first.fingerprint)
expect(vault.state.saves).toBe(1)
})
it('replaces a corrupt, mismatched or nearly expired identity', async () => {
const good = createSelfSignedCertificate()
const other = createSelfSignedCertificate()
expect(isUsableIdentity(good)).toBe(true)
expect(isUsableIdentity({ ...good, key: other.key })).toBe(false)
expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false)
expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false)
expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false)
const vault = memoryVault({ lanTls: { ...good, key: other.key } })
const renewed = await loadOrCreateLanIdentity(vault)
expect(renewed.created).toBe(true)
expect(renewed.fingerprint).not.toBe(good.fingerprint)
})
it('surfaces a vault failure instead of running without a stored key', async () => {
const vault = {
load: async () => {
throw new Error('vault closed')
},
save: async () => undefined
}
await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed')
})
})
+159
View File
@@ -0,0 +1,159 @@
/**
* mobile-lan-cert.ts
*
* Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy
* (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built
* by hand and signed with node:crypto.
*
* The phone never trusts a CA for this server. It pins the certificate by
* `fp` = SHA-256 of the certificate's DER bytes, base64url without padding
* (43 characters), which it learns from the pairing QR / rendezvous record.
*
* The private key lives only in the injected vault (MobileVault → Electron
* safeStorage → Windows DPAPI). Existing vault fields are preserved.
*/
import crypto from 'node:crypto'
export const MOBILE_LAN_CERT_DAYS = 3650
/** Regenerate when less than this is left; the phone must then pair again. */
const RENEW_BEFORE_MS = 30 * 24 * 3600e3
const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/
export interface MobileLanIdentity {
/** PEM certificate. */
cert: string
/** PEM PKCS#8 private key. Never leaves the main process. */
key: string
/** SHA-256 of the DER certificate, base64url, 43 characters. */
fingerprint: string
}
export interface MobileLanVault {
load: () => Promise<Record<string, any>>
save: (value: Record<string, any>) => Promise<void>
}
function der(tag: number, ...parts: Buffer[]): Buffer {
const body = Buffer.concat(parts)
let length: Buffer
if (body.length < 128) {
length = Buffer.from([body.length])
} else {
const bytes: number[] = []
for (let n = body.length; n > 0; n >>= 8) {
bytes.unshift(n & 255)
}
length = Buffer.from([0x80 | bytes.length, ...bytes])
}
return Buffer.concat([Buffer.from([tag]), length, body])
}
const seq = (...parts: Buffer[]) => der(0x30, ...parts)
const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex'))
const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value)
const utcTime = (date: Date) =>
der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii'))
const ECDSA_SHA256 = seq(oid('2a8648ce3d040302'))
const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8')))))
export function fingerprintOfDer(certDer: Uint8Array): string {
return crypto.createHash('sha256').update(certDer).digest('base64url')
}
/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */
export function fingerprintOfPem(certPem: string): string {
return fingerprintOfDer(new crypto.X509Certificate(certPem).raw)
}
export function createSelfSignedCertificate(
options: { commonName?: string; days?: number; now?: Date } = {}
): MobileLanIdentity {
const commonName = options.commonName ?? 'AITURK IDE'
const days = options.days ?? MOBILE_LAN_CERT_DAYS
const now = options.now ?? new Date()
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
const serial = crypto.randomBytes(16)
serial[0] = (serial[0] & 0x7f) | 0x01
const notBefore = new Date(now.getTime() - 24 * 3600e3)
const notAfter = new Date(now.getTime() + days * 24 * 3600e3)
if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) {
// UTCTime cannot carry these years; refuse rather than emit a wrong date.
throw new Error('mobile-lan-cert:validity-out-of-range')
}
const tbs = seq(
der(0xa0, int(Buffer.from([2]))), // v3
int(serial),
ECDSA_SHA256,
name(commonName),
seq(utcTime(notBefore), utcTime(notAfter)),
name(commonName),
publicKey.export({ type: 'spki', format: 'der' }) as Buffer
)
const signature = crypto.sign('sha256', tbs, privateKey)
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature))
const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n')
return {
cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`,
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
fingerprint: fingerprintOfDer(certDer)
}
}
/** True when the stored pair is a usable, unexpired, matching identity. */
export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity {
const row = value as MobileLanIdentity | null
if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) {
return false
}
try {
const x509 = new crypto.X509Certificate(row.cert)
return (
fingerprintOfDer(x509.raw) === row.fingerprint &&
x509.checkPrivateKey(crypto.createPrivateKey(row.key)) &&
new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS
)
} catch {
return false
}
}
/**
* Load the LAN identity from the vault, or create and store one. Stored under
* the vault's `lanTls` field; every other field is written back untouched.
* `created` tells the caller that previously paired phones no longer match.
*/
export async function loadOrCreateLanIdentity(
vault: MobileLanVault,
options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {}
): Promise<MobileLanIdentity & { created: boolean }> {
const now = options.now ? options.now() : new Date()
const secrets = (await vault.load()) ?? {}
if (isUsableIdentity(secrets.lanTls, now)) {
const { cert, key, fingerprint } = secrets.lanTls
return { cert, key, fingerprint, created: false }
}
const identity = (options.create ?? createSelfSignedCertificate)({ now })
await vault.save({ ...secrets, lanTls: identity })
return { ...identity, created: true }
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4
View File
@@ -286,6 +286,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
cancel: () => ipcRenderer.invoke('aiturk:login:cancel'),
overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli')
},
aiturkMobile: {
// Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs).
open: () => ipcRenderer.invoke('aiturk:mobile:open')
},
mcpOauth: {
// One-shot loopback listener for MCP OAuth against remote backends: bind
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.14",
"version": "1.0.0-beta.15",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
@@ -64,11 +64,13 @@ await build({
logLevel: 'info',
})
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cjs']) {
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-host.css', 'mobile-preload.cjs']) {
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
}
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
for (const name of ['transport.mjs', 'peer-identity.mjs', 'style.css', 'qr.mjs']) {
// The phone window only needs the shared stylesheet and the QR drawer (which
// imports ./vendor/qrcode.mjs, staged below).
for (const name of ['style.css', 'qr.mjs']) {
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
}
// A clean `npm ci` hoists the mobile vendor packages to the workspace root, so
@@ -84,5 +86,3 @@ function mobilePackageDir(name) {
}
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
cpSync(resolve(mobilePackageDir('jsqr'), 'dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js'))
cpSync(resolve(mobilePackageDir('jsqr'), 'LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt'))
+19 -1
View File
@@ -1,4 +1,5 @@
import { useStore } from '@nanostores/react'
import { IconDeviceMobile } from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router'
@@ -7,6 +8,7 @@ import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { useAiturkCopy } from '@/i18n/aiturk'
import { triggerHaptic } from '@/lib/haptics'
import {
Archive,
@@ -73,6 +75,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const aiturkCopy = useAiturkCopy()
const navigate = useNavigate()
const { hash, pathname, search } = useLocation()
@@ -249,6 +252,21 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.gateway,
onSelect: () => setActiveView('gateway')
},
// "Telefon bağlantısı" lives in its own small window (QR + code); this
// entry only opens it. Present only where the desktop bridge offers it.
...(window.hermesDesktop?.aiturkMobile
? [
{
active: false,
icon: IconDeviceMobile,
id: 'aiturk-phone',
label: aiturkCopy.mobile.nav,
onSelect: () => {
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
}
}
]
: []),
{
active: activeView === 'keybinds',
icon: Keyboard,
@@ -302,7 +320,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
+4
View File
@@ -331,6 +331,10 @@ declare global {
cancel: () => Promise<{ ok: true }>
overwriteCli: () => Promise<{ cli: AiturkCliStatus }>
}
/** "Telefon bağlantısı": opens the pairing window (electron/mobile-host.mjs). Windows only. */
aiturkMobile?: {
open: () => Promise<{ ok: true }>
}
/** One-shot loopback callback listener for MCP OAuth against remote
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
+8
View File
@@ -86,6 +86,10 @@ const en = {
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
confirmDelete: (name: string) => `Delete ${name}?`
},
mobile: {
nav: 'Phone connection',
openFailed: 'Could not open the phone connection window.'
},
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
downloads: 'Official downloads and updates',
source: 'Source code and license',
@@ -157,6 +161,10 @@ const tr: typeof en = {
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
confirmDelete: name => `${name} silinsin mi?`
},
mobile: {
nav: 'Telefon bağlantısı',
openFailed: 'Telefon bağlantısı penceresi açılamadı.'
},
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
downloads: 'Resmî indirmeler ve güncellemeler',
source: 'Kaynak kodu ve lisans',
+1 -1
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.14",
"version": "1.0.0-beta.15",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",