From 58dbb4523bead93050a2648663f98a6d97305989 Mon Sep 17 00:00:00 2001 From: yilsem Date: Mon, 5 Oct 2026 14:27:59 +0300 Subject: [PATCH] AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu). - publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429 yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter). - Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar. - Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi yeniden denenmez). - Gunluge yalniz "pair offer published / failed after N tries" duser; kod, lookup ve jeton yazilmaz. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf --- apps/desktop/electron/aiturk-login.test.ts | 51 +++ apps/desktop/electron/aiturk-login.ts | 33 +- apps/desktop/electron/main.ts | 2 +- apps/desktop/electron/mobile-host-ui.mjs | 9 +- apps/desktop/electron/mobile-host.css | 1 + apps/desktop/electron/mobile-host.html | 1 + apps/desktop/electron/mobile-host.mjs | 2 + .../desktop/electron/mobile-lan-proxy.test.ts | 344 +++++++++++++++++- apps/desktop/electron/mobile-lan-proxy.ts | 217 +++++++++-- .../electron/mobile-relay-client.test.ts | 62 +++- apps/desktop/electron/mobile-relay-client.ts | 79 +++- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 13 files changed, 729 insertions(+), 76 deletions(-) diff --git a/apps/desktop/electron/aiturk-login.test.ts b/apps/desktop/electron/aiturk-login.test.ts index 3ede9a5..1ccecd5 100644 --- a/apps/desktop/electron/aiturk-login.test.ts +++ b/apps/desktop/electron/aiturk-login.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { AITURK_CLI_KEY_HOLD_MS, + AITURK_LOGIN_START_RETRY_DELAY_MS, type AiturkCliExit, AiturkLoginError, createAiturkCliRunner, @@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a function harness( options: { start?: { status: number; body?: unknown } + /** Thrown by successive `device-auth/start` calls before any answer is given. */ + startErrors?: Error[] polls?: ({ status: number; body?: unknown } | Error)[] cliExits?: AiturkCliExit[] validate?: unknown @@ -47,6 +50,7 @@ function harness( ) { const calls: { url: string; init: any }[] = [] const polls = [...(options.polls ?? [])] + const startErrors = [...(options.startErrors ?? [])] const cliExits = [...(options.cliExits ?? [0])] const clock = { now: 1_000_000 } const timers: { fn: () => void; ms: number; cleared: boolean }[] = [] @@ -55,6 +59,12 @@ function harness( calls.push({ url, init }) if (url.endsWith('/start')) { + const failure = startErrors.shift() + + if (failure) { + throw failure + } + const r = options.start ?? { status: 201, body: startBody() } return { status: r.status, json: async () => r.body } @@ -154,6 +164,47 @@ describe('aiturk login: start', () => { await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable') }) + it('tries start once more after a thrown network error (first request of a long-idle process)', async () => { + const h = harness({ startErrors: [new TypeError('fetch failed')] }) + const pending = h.login.start() + + await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true)) + expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500) + expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1) + expect(h.openExternal).not.toHaveBeenCalled() + h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn() + + expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 }) + expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2) + // One code only: the approval page is opened once. + expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI) + }) + + it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => { + const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] }) + const failing = twice.login.start().catch(e => e) + + await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true)) + twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn() + expect((await failing).message).toBe('aiturk-login:unavailable') + expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2) + expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1) + + for (const status of [500, 503, 429, 422]) { + const answered = harness({ start: { status } }) + + await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/) + expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1) + expect(answered.timers).toHaveLength(0) + } + + const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] }) + + await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable') + expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1) + expect(timedOut.timers).toHaveLength(0) + }) + it('rejects malformed codes', async () => { await expect( harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start() diff --git a/apps/desktop/electron/aiturk-login.ts b/apps/desktop/electron/aiturk-login.ts index 732f9ca..d3b1686 100644 --- a/apps/desktop/electron/aiturk-login.ts +++ b/apps/desktop/electron/aiturk-login.ts @@ -22,6 +22,8 @@ export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online' export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000 +/** Wait before the single second try of `device-auth/start` after a thrown network error. */ +export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500 export const AITURK_CLI_TIMEOUT_MS = 60_000 export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000 @@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) { await cancelOnServer(previous.deviceCode) } - let response: AiturkFetchResponse + let response: AiturkFetchResponse | undefined - try { - response = await post('/v1/aiturk/device-auth/start', null, { - client: 'ide', - device_name: deviceName(deps.hostname()), - also_cli: true - }) - } catch { - throw new AiturkLoginError('unavailable') + // A long-idle process can fail its first request on a dead pooled + // connection. Only a THROWN network error (no HTTP answer, so no code was + // handed to us) is tried once more; an HTTP answer is never retried, so + // one start cannot knowingly leave two codes behind. A timeout already + // used its whole budget and is not retried. + for (let attempt = 1; response === undefined; attempt++) { + try { + response = await post('/v1/aiturk/device-auth/start', null, { + client: 'ide', + device_name: deviceName(deps.hostname()), + also_cli: true + }) + } catch (error) { + const name = (error as null | { name?: unknown })?.name + + if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') { + throw new AiturkLoginError('unavailable') + } + + await new Promise(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS)) + } } if (response.status === 429) { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b168b22..8f2b83f 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -17893,7 +17893,7 @@ function getMobileLink(): MobileLink { // API traffic is not logged; pairing and refusals are, for diagnosis. Relay // lines are status changes only ("relay connected", "relay failed:"). log: line => { - if (/^relay /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { + if (/^(?:relay|pair) /.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 3586f78..2e96d08 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -20,7 +20,7 @@ const TEXT = { 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.', + codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene', 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.', @@ -56,7 +56,7 @@ const TEXT = { 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.', + codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again', 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.', @@ -84,7 +84,7 @@ 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-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] + ['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key] let current = null, drawnQr = '', busy = false function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text } @@ -121,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] + // Offered only once every background try is over; while they run the line above says "hazırlanıyor…". + $('pair-republish').hidden = state.pairing.codePath !== 'unavailable' $('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() @@ -155,6 +157,7 @@ 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')) }) +$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) }) $('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) }) } diff --git a/apps/desktop/electron/mobile-host.css b/apps/desktop/electron/mobile-host.css index 6b9d6f4..8d0df06 100644 --- a/apps/desktop/electron/mobile-host.css +++ b/apps/desktop/electron/mobile-host.css @@ -24,3 +24,4 @@ button.link { background:none; color:var(--accent); min-height:0; padding:0; mar .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 } +#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px } diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index 6ea6abf..ba68b1e 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -20,6 +20,7 @@

+

diff --git a/apps/desktop/electron/mobile-host.mjs b/apps/desktop/electron/mobile-host.mjs index 7022744..4e27f29 100644 --- a/apps/desktop/electron/mobile-host.mjs +++ b/apps/desktop/electron/mobile-host.mjs @@ -20,6 +20,8 @@ export function createMobileWindow({ assets, link, show = true }) { disable: () => link.disable(), 'pair.begin': () => link.pairBegin(), 'pair.cancel': () => link.pairCancel(), + // Publishes the running pairing's rendezvous record again; the code and the QR stay as they are. + 'pair.republish': () => link.pairRepublish(), devices: () => link.devices(), 'device.revoke': params => link.deviceRevoke(params?.deviceId), // The only actions that may raise a UAC prompt; both are explicit button presses. diff --git a/apps/desktop/electron/mobile-lan-proxy.test.ts b/apps/desktop/electron/mobile-lan-proxy.test.ts index e71a6d9..4f587bb 100644 --- a/apps/desktop/electron/mobile-lan-proxy.test.ts +++ b/apps/desktop/electron/mobile-lan-proxy.test.ts @@ -19,6 +19,7 @@ import { WebSocket, WebSocketServer } from 'ws' import { createSelfSignedCertificate } from './mobile-lan-cert' import { buildPairQrPayload, + cancelPairOffer, createLanProxy, createMobileLink, createWindowsFirewall, @@ -34,6 +35,8 @@ import { type MobileLinkEvent, normalizePairCode, PAIR_CODE_ALPHABET, + PAIR_OFFER_MAX_TRIES, + PAIR_OFFER_RETRY_DELAYS_MS, pairLookup, parseFirewallRuleOutput, parseMobileLinkStored, @@ -745,10 +748,19 @@ function linkHarness(over: Record = {}) { const events: MobileLinkEvent[] = [] const logs: string[] = [] const hostSockets: FakeHostSocket[] = [] + /** Outcomes of successive `POST /offers` calls (status or 'throw'); afterwards `state.offerStatus` applies. */ + const offerPlan: (number | 'throw')[] = [...(over.offerPlan ?? [])] + /** Waits the controller asked for between tries (real values); they are shortened to 1 ms here. */ + const retrySleeps: number[] = [] + /** With `holdRetries` a wait ends only when the test calls the function. */ + const heldRetries: (() => void)[] = [] + const RETRY_WAITS = new Set([...PAIR_OFFER_RETRY_DELAYS_MS]) const state = { remote: false, offerStatus: over.offerStatus ?? 201, + /** While set, `POST /offers` answers only after it resolves. */ + offerGate: null as null | Promise, /** false = `POST /sessions` fails (no internet). */ sessions: over.sessions ?? true, sessionCount: 0, @@ -809,13 +821,20 @@ function linkHarness(over: Record = {}) { return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) } } - if (state.offerStatus === 'throw') { + const isOffer = url.endsWith('/offers') + const offerOutcome = isOffer && offerPlan.length ? offerPlan.shift() : state.offerStatus + + if (isOffer && state.offerGate) { + await state.offerGate + } + + if (offerOutcome === 'throw') { throw new Error('network down') } - if (url.endsWith('/offers')) { + if (isOffer) { return { - status: state.offerStatus, + status: offerOutcome, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 }) } } @@ -835,6 +854,23 @@ function linkHarness(over: Record = {}) { platform: 'win32', listenHost: LAN_HOST, ports: [0], + // Real timers, except the waits between tries of a request: those are recorded and cut to 1 ms. + setTimer: (fn: () => void, ms: number) => { + if (!RETRY_WAITS.has(ms)) { + return setTimeout(fn, ms) + } + + retrySleeps.push(ms) + + if (over.holdRetries) { + heldRetries.push(fn) + + return null + } + + return setTimeout(fn, 1) + }, + clearTimer: (handle: unknown) => clearTimeout(handle as NodeJS.Timeout), log: (line: string) => logs.push(line) } @@ -850,7 +886,7 @@ function linkHarness(over: Record = {}) { const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query') const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers')) - return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets } + return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries } } describe('mobile link controller', () => { @@ -1278,6 +1314,306 @@ describe('mobile link controller', () => { }) }) +describe('mobile link controller: rendezvous record retries', () => { + const lookupOf = (h: ReturnType, index: number) => JSON.parse(h.offers()[index].init.body).lookup + const cancels = (h: ReturnType) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel')) + + const noSecretInLogs = (h: ReturnType, code: string) => { + const text = h.logs.join('\n') + + expect(text).not.toContain(code) + expect(text).not.toContain(pairLookup(code)) + expect(text).not.toContain('tsmp_') + } + + it('first request rejects, the second answers 201: the code path becomes ready (one lost request does not close it)', async () => { + const h = linkHarness({ offerPlan: ['throw', 201] }) + + await h.link.enable() + + const state = await h.link.pairBegin() + + expect(state.pairing!.codePath).toBe('pending') + await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) + expect(h.offers()).toHaveLength(2) + expect(h.retrySleeps).toEqual([500]) + // Same record both times, and each try has its own timeout signal. + expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) + expect(h.offers()[1].init.signal).not.toBe(h.offers()[0].init.signal) + expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) + expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer published']) + noSecretInLogs(h, state.pairing!.code) + await h.link.stop() + }) + + it('two 503 answers, then 201: ready on the third try', async () => { + const h = linkHarness({ offerPlan: [503, 503, 201] }) + + await h.link.enable() + await h.link.pairBegin() + await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) + expect(h.offers()).toHaveLength(3) + expect(h.retrySleeps).toEqual([500, 1500]) + expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) + await h.link.stop() + }) + + it('a lost answer followed by 409 means the first request landed: ready, and no cancel is sent without a token', async () => { + const h = linkHarness({ offerPlan: ['throw', 409] }) + + await h.link.enable() + await h.link.pairBegin() + await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) + expect(h.offers()).toHaveLength(2) + expect((await h.link.pairCancel()).pairing).toBeNull() + await new Promise(resolve => setTimeout(resolve, 50)) + expect(cancels(h)).toEqual([]) + await h.link.stop() + expect(cancels(h)).toEqual([]) + }) + + it('422 is final: one try, unavailable', async () => { + const h = linkHarness({ offerPlan: [422, 201] }) + + await h.link.enable() + + const state = await h.link.pairBegin() + + await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(h.offers()).toHaveLength(1) + expect(h.retrySleeps).toEqual([]) + expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 1 try']) + noSecretInLogs(h, state.pairing!.code) + await h.link.stop() + }) + + it('four failing tries: unavailable only after the fourth, never earlier', async () => { + const h = linkHarness({ offerPlan: ['throw', 503, 429, 'throw', 201], holdRetries: true }) + + await h.link.enable() + + const state = await h.link.pairBegin() + + for (let tries = 1; tries <= 3; tries++) { + await until(() => h.offers().length === tries && h.heldRetries.length === 1) + // Still being tried: the window keeps saying "hazırlanıyor…". + expect((await h.link.state()).pairing!.codePath).toBe('pending') + expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) + h.heldRetries.shift()!() + } + + await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) + expect(h.offers()).toHaveLength(4) + expect(h.retrySleeps).toEqual([500, 1500, 3000]) + expect(h.heldRetries).toEqual([]) + expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 4 tries']) + noSecretInLogs(h, state.pairing!.code) + await h.link.stop() + }) + + it('cancelling the pairing stops the tries; a record that lands after the cancel is cancelled on the gateway', async () => { + const h = linkHarness({ offerPlan: ['throw', 201], holdRetries: true }) + + await h.link.enable() + await h.link.pairBegin() + await until(() => h.offers().length === 1 && h.heldRetries.length === 1) + expect((await h.link.pairCancel()).pairing).toBeNull() + // The wait ends after the pairing is gone: no further request. + h.heldRetries.shift()!() + await new Promise(resolve => setTimeout(resolve, 50)) + expect(h.offers()).toHaveLength(1) + expect(cancels(h)).toEqual([]) + expect(h.logs.filter(line => line.startsWith('pair '))).toEqual([]) + + // A NEW pairing is not confused with the old one, and a request in flight when pairing ends is cleaned up. + let release: () => void = () => undefined + + h.state.offerGate = new Promise(resolve => { + release = resolve + }) + await h.link.pairBegin() + await until(() => h.offers().length === 2) + await h.link.pairCancel() + release() + await until(() => cancels(h).length === 1) + expect(cancels(h)[0].init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`) + await h.link.stop() + }) + + it('pair.republish publishes the running pairing again: new request, same code, same QR, same expiry', async () => { + const h = linkHarness({ offerPlan: [422] }) + + await h.link.enable() + + const begun = (await h.link.pairBegin()).pairing! + + await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) + expect(h.offers()).toHaveLength(1) + + const again = (await h.link.pairRepublish()).pairing! + + expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' }) + await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready') + expect(h.offers()).toHaveLength(2) + expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) + expect(JSON.parse(h.offers()[1].init.body)).toEqual(JSON.parse(h.offers()[0].init.body)) + + // Nothing to do while ready (or pending), and nothing without a pairing. + expect((await h.link.pairRepublish()).pairing).toMatchObject({ code: begun.code, codePath: 'ready' }) + expect(h.offers()).toHaveLength(2) + + // The code on screen still pairs the phone. + const claim = await call((await h.link.state()).port as number, 'POST', '/aiturk/pair/claim', { + ca: h.vault.value.lanTls.cert, + body: { code: begun.code, device_name: 'Pixel' } + }) + + expect(claim.status).toBe(200) + await until(() => h.events.some(event => event.type === 'paired')) + expect((await h.link.pairRepublish()).pairing).toBeNull() + expect(h.offers()).toHaveLength(2) + await h.link.stop() + }) + + it('pair.republish after four lost requests: a 409 on its first try is our own record (ready); after a 422 it is not', async () => { + const lost = linkHarness({ offerPlan: ['throw', 'throw', 'throw', 'throw', 409] }) + + await lost.link.enable() + await lost.link.pairBegin() + await until(() => lost.events.some(event => event.state.pairing?.codePath === 'unavailable')) + expect(lost.offers()).toHaveLength(4) + await lost.link.pairRepublish() + await until(() => lost.events.at(-1)?.state.pairing?.codePath === 'ready') + expect(lost.offers()).toHaveLength(5) + await lost.link.stop() + expect(cancels(lost)).toEqual([]) + + const refused = linkHarness({ offerPlan: [422, 409] }) + + await refused.link.enable() + await refused.link.pairBegin() + await until(() => refused.events.some(event => event.state.pairing?.codePath === 'unavailable')) + await refused.link.pairRepublish() + await until(() => refused.offers().length === 2 && refused.events.at(-1)?.state.pairing?.codePath === 'unavailable') + await refused.link.stop() + }) +}) + +describe('publishPairOffer / cancelPairOffer', () => { + const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] } + const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` } + + function plan(outcomes: (number | 'throw')[]) { + const sleeps: number[] = [] + const logs: string[] = [] + let calls = 0 + + const fetcher = async () => { + const outcome = outcomes[calls++] ?? 201 + + if (outcome === 'throw') { + throw new Error('fetch failed') + } + + return { status: outcome, json: async () => good } + } + + const options = { + sleep: async (ms: number) => { + sleeps.push(ms) + }, + log: (line: string) => logs.push(line) + } + + return { fetcher, options, sleeps, logs, calls: () => calls } + } + + it('waits 0.5 s, 1.5 s and 3 s between at most four tries', async () => { + const p = plan(['throw', 500, 429, 'throw', 201]) + + expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull() + expect(p.calls()).toBe(PAIR_OFFER_MAX_TRIES) + expect(p.calls()).toBe(4) + expect(p.sleeps).toEqual([500, 1500, 3000]) + expect(p.sleeps).toEqual([...PAIR_OFFER_RETRY_DELAYS_MS]) + expect(p.logs).toEqual(['offer failed after 4 tries']) + }) + + it('retries a thrown error, 5xx and 429; 201 ends it', async () => { + for (const first of ['throw', 500, 502, 503, 504, 429] as const) { + const p = plan([first, 201]) + + expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual(good) + expect(p.calls()).toBe(2) + expect(p.logs).toEqual(['offer published']) + } + }) + + it('does not retry 422 or any other final answer; a first-try 409 is somebody else’s record', async () => { + for (const status of [422, 400, 401, 403, 404, 409, 200, 302]) { + const p = plan([status, 201]) + + expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull() + expect(p.calls()).toBe(1) + expect(p.sleeps).toEqual([]) + expect(p.logs).toEqual(['offer failed after 1 try']) + } + }) + + it('409 after a lost answer or a 5xx is our own record (no token); 409 after 429 is not', async () => { + for (const first of ['throw', 502] as const) { + const p = plan([first, 409]) + + expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual({ offerId: null, cancelToken: null }) + expect(p.calls()).toBe(2) + expect(p.logs).toEqual(['offer published']) + } + + const limited = plan([429, 409]) + + expect(await publishPairOffer(limited.fetcher, input, limited.options)).toBeNull() + expect(limited.calls()).toBe(2) + + const told = plan([409]) + + expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null }) + }) + + it('stops as soon as the pairing is over, without a log line', async () => { + const p = plan(['throw', 'throw', 201]) + let over = false + + const result = await publishPairOffer(p.fetcher, input, { + ...p.options, + cancelled: () => over, + sleep: async ms => { + p.sleeps.push(ms) + over = true + } + }) + + expect(result).toBeNull() + expect(p.calls()).toBe(1) + expect(p.logs).toEqual([]) + }) + + it('cancelPairOffer sends nothing when there is no token', async () => { + const urls: string[] = [] + + const fetcher = async (url: string) => { + urls.push(url) + + return { status: 200, json: async () => ({}) } + } + + await cancelPairOffer(fetcher, { offerId: null, cancelToken: null }) + expect(urls).toEqual([]) + await cancelPairOffer(fetcher, good) + expect(urls).toEqual(['https://turkservis.online/v1/aiturk/mobile-pair/cancel']) + }) +}) + describe('pure helpers', () => { it('pairing code: alphabet, normalisation, display form, lookup digest', () => { for (let i = 0; i < 200; i++) { diff --git a/apps/desktop/electron/mobile-lan-proxy.ts b/apps/desktop/electron/mobile-lan-proxy.ts index e317db0..cde36e6 100644 --- a/apps/desktop/electron/mobile-lan-proxy.ts +++ b/apps/desktop/electron/mobile-lan-proxy.ts @@ -247,9 +247,37 @@ export interface PairFetchResponse { export type PairFetch = (url: string, init: Record) => Promise +/** + * A published rendezvous record. Both fields are null when the record is known + * to exist but its answer never reached us (an earlier try failed on the wire + * and the next one got 409): the code path works, there is just nothing to + * cancel — the record expires on its own after five minutes. + */ export interface PairOffer { - offerId: string - cancelToken: string + offerId: null | string + cancelToken: null | string +} + +export const PAIR_OFFER_MAX_TRIES = 4 +/** Waits between the tries: ~0.5 s, 1.5 s, 3 s. */ +export const PAIR_OFFER_RETRY_DELAYS_MS: readonly number[] = [500, 1500, 3000] + +export interface PairOfferOptions { + /** Injected wait (the controller passes its own timer). Default: a real timer. */ + sleep?: (ms: number) => Promise + delaysMs?: readonly number[] + maxTries?: number + /** true = the pairing this record belongs to is over: stop trying. */ + cancelled?: () => boolean + /** "offer published" / "offer failed after N tries" only — never the code, the lookup or a token. */ + log?: (line: string) => void + /** + * An earlier publish for the SAME code may have reached the gateway (its + * answer was lost), so a 409 on the very first try also means "it is there". + */ + earlierTryMayHaveLanded?: boolean + /** Called when a try ended without a usable answer (thrown error / 5xx), i.e. it may have been stored. */ + onMayHaveLanded?: () => void } function pairFetchInit(body: unknown, headers: Record = {}) { @@ -263,12 +291,22 @@ function pairFetchInit(body: unknown, headers: Record = {}) { } /** - * Publish the rendezvous record. Returns null on any failure — the QR path - * keeps working and the UI shows "kodla bağlanma kullanılamıyor". + * Publish the rendezvous record. Returns null when it could not be published — + * the QR path keeps working and the UI shows "kodla bağlanma kullanılamıyor". + * + * Up to four tries. A long-idle process can lose its first request on a dead + * pooled keep-alive connection, and one lost request must not close the code + * path for the whole five minutes: + * - a thrown network error, 5xx and 429 are tried again; + * - 422 and every other answer are final (the request itself is wrong); + * - 409 after a try that failed on the wire / with 5xx means that try DID + * land and only its answer was lost: the record exists, the code path is + * ready (no cancel token is known for it). */ export async function publishPairOffer( fetcher: PairFetch, - input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string } + input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string }, + options: PairOfferOptions = {} ): Promise { const relay = isRelayId(input.relay) ? input.relay : null @@ -282,34 +320,93 @@ export async function publishPairOffer( return null } - try { - const response = await fetcher( - `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, - pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) }) - ) + const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) + const delays = options.delaysMs ?? PAIR_OFFER_RETRY_DELAYS_MS + const maxTries = Math.max(1, options.maxTries ?? PAIR_OFFER_MAX_TRIES) + const cancelled = options.cancelled ?? (() => false) + const body = { lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) } + let mayHaveLanded = options.earlierTryMayHaveLanded === true + let tries = 0 - if (response.status !== 201) { - return null - } + const failed = () => { + options.log?.(`offer failed after ${tries} ${tries === 1 ? 'try' : 'tries'}`) - const data = (await response.json()) as null | Record - - if (!data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId)) { - return null - } - - if (typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken)) { - return null - } - - return { offerId: data.offerId, cancelToken: data.cancelToken } - } catch { return null } + + const published = (offer: PairOffer) => { + options.log?.('offer published') + + return offer + } + + while (tries < maxTries) { + if (cancelled()) { + return null + } + + tries++ + + let status: number + let data: null | Record = null + + try { + // A fresh init per try: the timeout signal must not be shared between tries. + const response = await fetcher(`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit(body)) + + status = response.status + + if (status === 201) { + data = (await response.json()) as null | Record + } + } catch { + // No (complete) answer: the request may or may not have been stored. + status = 0 + } + + if (status === 201) { + if ( + !data || + typeof data.offerId !== 'string' || + !UUID_RE.test(data.offerId) || + typeof data.cancelToken !== 'string' || + !CANCEL_TOKEN_RE.test(data.cancelToken) + ) { + return failed() + } + + return published({ offerId: data.offerId, cancelToken: data.cancelToken }) + } + + if (status === 409 && mayHaveLanded) { + return published({ offerId: null, cancelToken: null }) + } + + const transient = status === 0 || status === 429 || (status >= 500 && status <= 599) + + if (!transient) { + return failed() + } + + if (status !== 429) { + mayHaveLanded = true + options.onMayHaveLanded?.() + } + + if (tries < maxTries) { + await sleep(delays[Math.min(tries - 1, delays.length - 1)] ?? 0) + } + } + + return cancelled() ? null : failed() } -/** Best effort; the record expires on its own after five minutes anyway. */ +/** Best effort; the record expires on its own after five minutes anyway. No token = nothing to cancel. */ export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise { + if (!offer.cancelToken) { + return + } + try { await fetcher( `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`, @@ -1380,6 +1477,8 @@ interface PairSession { addresses: string[] port: number fingerprint: string + /** A publish got no answer at least once: a later 409 for this code is our own record. */ + offerMayHaveLanded: boolean } export function createMobileLink(deps: MobileLinkDeps) { @@ -1526,6 +1625,42 @@ export function createMobileLink(deps: MobileLinkDeps) { } } + const sleep = (ms: number) => new Promise(resolve => void setTimer(resolve, ms)) + + /** + * Publishes (or publishes again) the rendezvous record of `current` in the + * background; the code path stays `pending` until every try is over. The + * code and the QR are never touched here. + */ + function publishOffer(current: PairSession, relayId: null | string) { + current.codePath = 'pending' + + const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId } + + void publishPairOffer(deps.fetch, input, { + sleep, + cancelled: () => session !== current, + log: line => deps.log?.(`pair ${line}`), + earlierTryMayHaveLanded: current.offerMayHaveLanded, + onMayHaveLanded: () => { + current.offerMayHaveLanded = true + } + }).then(offer => { + if (session !== current) { + // Pairing ended meanwhile; do not leave the record behind. + if (offer) { + void cancelPairOffer(deps.fetch, offer) + } + + return + } + + current.offer = offer + current.codePath = offer ? 'ready' : 'unavailable' + emit() + }) + } + /** The relay registration changed while a QR is on screen: show the current address. */ function refreshSessionQr(data: MobileLinkStored) { if (!session) { @@ -1892,27 +2027,31 @@ export function createMobileLink(deps: MobileLinkDeps) { }, Math.max(0, expiresAt - now()) + 50), addresses, port, - fingerprint + fingerprint, + offerMayHaveLanded: false } session = current ensureRelay(data)?.start() // Not awaited: the QR must not wait for the gateway. - void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint, relay: relayUrl }).then(offer => { - if (session !== current) { - // Pairing ended meanwhile; do not leave the record behind. - if (offer) { - void cancelPairOffer(deps.fetch, offer) - } + publishOffer(current, relayUrl) - return - } + return snapshot(data) + }), - current.offer = offer - current.codePath = offer ? 'ready' : 'unavailable' - emit() - }) + /** + * "Yeniden dene" next to "Kodla bağlanma şu an kullanılamıyor": publishes + * the rendezvous record of the RUNNING pairing again. Same code, same QR, + * same expiry; nothing happens unless the code path is `unavailable`. + */ + pairRepublish: () => + serial(async () => { + const data = await load() + + if (session?.codePath === 'unavailable' && proxy?.pairingOpen() && session) { + publishOffer(session, data.relayId) + } return snapshot(data) }), diff --git a/apps/desktop/electron/mobile-relay-client.test.ts b/apps/desktop/electron/mobile-relay-client.test.ts index 5bd10d0..9cd7ad6 100644 --- a/apps/desktop/electron/mobile-relay-client.test.ts +++ b/apps/desktop/electron/mobile-relay-client.test.ts @@ -28,6 +28,7 @@ import { createRelayClient, encodeRelayFrame, isRelayId, + MOBILE_NETWORK_RETRY_DELAY_MS, parseRelaySession, registerRelaySession, RELAY_BACKOFF_MS, @@ -925,10 +926,15 @@ describe('relay client', () => { }) it('a failing registration is a fixed code (no upstream text) and is retried; renew() swaps the registration only when the new one is stored', async () => { - const h = clientHarness({ sessions: ['throw', 429, 500, 201, 500, 201] }) + const h = clientHarness({ sessions: ['throw', 'throw', 429, 500, 201, 500, 201] }) h.client.start() await h.settle() + // A thrown network error gets one more try 0.5 s later; until then nothing is reported as failed. + expect(h.client.state()).toEqual({ status: 'connecting', code: null }) + h.fire(500) + await h.settle() + expect(h.fetches).toHaveLength(2) expect(h.client.state()).toEqual({ status: 'failed', code: 'register_failed' }) expect(h.sockets).toHaveLength(0) h.fire(2000) @@ -982,6 +988,60 @@ describe('relay client', () => { broken.stop() }) + it('registration: a thrown network error is tried once more after 0.5 s; an HTTP answer or a timeout is not', async () => { + const h = clientHarness({ sessions: ['throw', 201] }) + const pending = h.client.ensureRegistered() + + await h.settle() + expect(h.fetches).toHaveLength(1) + expect(h.timers.map(timer => timer.ms)).toEqual([MOBILE_NETWORK_RETRY_DELAY_MS]) + expect(MOBILE_NETWORK_RETRY_DELAY_MS).toBe(500) + h.fire(500) + expect(await pending).toBe(RELAY_IDS[0]) + expect(h.fetches).toHaveLength(2) + expect(h.fetches[1].init.signal).not.toBe(h.fetches[0].init.signal) + expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: false }]) + + // The function itself: exactly one extra try, only for a thrown error. + const sleeps: number[] = [] + + const run = async (outcomes: (number | 'throw' | 'timeout')[]) => { + let calls = 0 + + const result = await registerRelaySession( + async () => { + const outcome = outcomes[calls++] ?? 201 + + if (outcome === 'throw') { + throw new TypeError('fetch failed') + } + + if (outcome === 'timeout') { + throw new DOMException('The operation was aborted due to timeout', 'TimeoutError') + } + + return { status: outcome, json: async () => ({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) } + }, + { + sleep: async ms => { + sleeps.push(ms) + } + } + ) + + return { result, calls } + } + + expect(await run(['throw', 201])).toEqual({ result: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, calls: 2 }) + expect(await run(['throw', 'throw', 201])).toEqual({ result: { error: 'register_failed' }, calls: 2 }) + expect(await run(['throw', 429])).toEqual({ result: { error: 'rate_limited' }, calls: 2 }) + expect(sleeps).toEqual([500, 500, 500]) + expect(await run([500, 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 }) + expect(await run([429, 201])).toEqual({ result: { error: 'rate_limited' }, calls: 1 }) + expect(await run(['timeout', 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 }) + expect(sleeps).toHaveLength(3) + }) + it('streams: opened / close, bytes copied untouched in ≤ 64 KiB frames, unknown ids ignored, stream and buffer limits', async () => { const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } }) diff --git a/apps/desktop/electron/mobile-relay-client.ts b/apps/desktop/electron/mobile-relay-client.ts index f807d11..c8a852b 100644 --- a/apps/desktop/electron/mobile-relay-client.ts +++ b/apps/desktop/electron/mobile-relay-client.ts @@ -92,24 +92,67 @@ export type RelayFailureCode = | 'busy' | 'timeout' -/** `POST /sessions`; a fixed failure code instead of any upstream text. */ -export async function registerRelaySession(fetcher: RelayFetch): Promise { - try { - const response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: '{}', - redirect: 'error', - signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS) - }) +/** Wait before the single second try of a request whose first try never got an answer. */ +export const MOBILE_NETWORK_RETRY_DELAY_MS = 500 - if (response.status !== 201) { - return { error: response.status === 429 ? 'rate_limited' : 'register_failed' } +/** An aborted / timed-out request already used its whole budget: it is not tried again. */ +export function isTimeoutError(error: unknown): boolean { + const name = (error as null | { name?: unknown })?.name + + return name === 'TimeoutError' || name === 'AbortError' +} + +export interface NetworkRetryOptions { + /** Injected wait (tests: fake clock). Default: a real timer. */ + sleep?: (ms: number) => Promise + retryDelayMs?: number +} + +const realSleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * `POST /sessions`; a fixed failure code instead of any upstream text. + * + * A process that sat idle for a long time can fail its first request on a dead + * pooled keep-alive connection. So a THROWN network error (no HTTP answer at + * all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry. + */ +export async function registerRelaySession( + fetcher: RelayFetch, + options: NetworkRetryOptions = {} +): Promise { + const sleep = options.sleep ?? realSleep + + for (let attempt = 1; ; attempt++) { + let response: RelayFetchResponse + + try { + response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: '{}', + redirect: 'error', + signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS) + }) + } catch (error) { + if (attempt >= 2 || isTimeoutError(error)) { + return { error: 'register_failed' } + } + + await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS) + + continue } - return parseRelaySession(await response.json()) ?? { error: 'register_failed' } - } catch { - return { error: 'register_failed' } + try { + if (response.status !== 201) { + return { error: response.status === 429 ? 'rate_limited' : 'register_failed' } + } + + return parseRelaySession(await response.json()) ?? { error: 'register_failed' } + } catch { + return { error: 'register_failed' } + } } } @@ -221,6 +264,8 @@ export function createRelayClient(deps: RelayClientDeps) { let retryTimer: unknown = null let silenceTimer: unknown = null const streams = new Map() + /** Same (injectable) clock as every other wait of this client. */ + const sleep = (ms: number) => new Promise(resolve => void setTimer(resolve, ms)) function setState(status: RelayStatus, code: null | RelayFailureCode = null) { if (state.status === status && state.code === code) { @@ -270,7 +315,7 @@ export function createRelayClient(deps: RelayClientDeps) { return stored } - const fresh = await registerRelaySession(deps.fetch) + const fresh = await registerRelaySession(deps.fetch, { sleep }) if ('error' in fresh) { return fresh @@ -626,7 +671,7 @@ export function createRelayClient(deps: RelayClientDeps) { * only once the new one is stored; false = nothing changed. */ async renew(): Promise { - const fresh = await registerRelaySession(deps.fetch) + const fresh = await registerRelaySession(deps.fetch, { sleep }) if ('error' in fresh) { return false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 094f71a..ac59b6e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/package-lock.json b/package-lock.json index 6b45ff8..17ab4f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",