Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ed9a6b2de | ||
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 |
No files matched your search
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
||||
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
||||
* no network, browser or real CLI is touched. The fixture "keys" below are
|
||||
* obviously fake placeholders in the right shape.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
createAiturkLogin,
|
||||
resolveAiturkCliPath
|
||||
} from './aiturk-login'
|
||||
|
||||
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
||||
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
||||
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
const startBody = (over: Record<string, unknown> = {}) => ({
|
||||
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
deviceCode: DEVICE_CODE,
|
||||
userCode: 'ABCD2345',
|
||||
expiresIn: 600,
|
||||
interval: 3,
|
||||
verificationUri: URI,
|
||||
...over
|
||||
})
|
||||
|
||||
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
start?: { status: number; body?: unknown }
|
||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
save?: unknown
|
||||
openExternal?: () => boolean
|
||||
} = {}
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
|
||||
const fetch = vi.fn(async (url: string, init: any) => {
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
if (url.endsWith('/poll')) {
|
||||
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
||||
|
||||
if (r instanceof Error) {
|
||||
throw r
|
||||
}
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
return { status: 200, json: async () => ({}) }
|
||||
})
|
||||
|
||||
const backendRequest = vi.fn(async (request: any) => {
|
||||
if (request.path.endsWith('/validate')) {
|
||||
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
||||
}
|
||||
|
||||
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
||||
})
|
||||
|
||||
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
||||
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
||||
)
|
||||
|
||||
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
||||
|
||||
const login = createAiturkLogin({
|
||||
fetch,
|
||||
openExternal,
|
||||
hostname: () => 'TEST-PC',
|
||||
backendRequest,
|
||||
runCli,
|
||||
now: () => clock.now,
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, ms, cleared: false }
|
||||
timers.push(timer)
|
||||
|
||||
return timer
|
||||
},
|
||||
clearTimer: handle => {
|
||||
;(handle as { cleared: boolean }).cleared = true
|
||||
},
|
||||
timeoutSignal: () => undefined
|
||||
})
|
||||
|
||||
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
||||
}
|
||||
|
||||
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
||||
|
||||
describe('aiturk login: start', () => {
|
||||
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
||||
const h = harness()
|
||||
const started = await h.login.start({ profile: 'work' })
|
||||
|
||||
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(leaks(started)).toBe(false)
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
|
||||
const { url, init } = h.calls[0]
|
||||
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.redirect).toBe('error')
|
||||
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
||||
expect(init.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
||||
'https://turkservis.online/account/aiturk/device-login/../../x',
|
||||
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
''
|
||||
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
||||
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
||||
|
||||
const error = await h.login.start().catch(e => e)
|
||||
expect(error).toBeInstanceOf(AiturkLoginError)
|
||||
expect(error.message).toBe('aiturk-login:invalid-response')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
// The issued code is revoked instead of being left approvable.
|
||||
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
})
|
||||
|
||||
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
||||
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
||||
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
})
|
||||
|
||||
it('rejects malformed codes', async () => {
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
})
|
||||
|
||||
it('keeps a single login: a second start revokes the first code', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
await h.login.start()
|
||||
|
||||
const urls = h.calls.map(c => c.url.split('/').pop())
|
||||
expect(urls).toEqual(['start', 'cancel', 'start'])
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
})
|
||||
|
||||
it('cancels the code when the browser cannot be opened', async () => {
|
||||
const h = harness({ openExternal: () => false })
|
||||
|
||||
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
||||
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: poll', () => {
|
||||
it('reports pending while the user has not approved', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
expect(h.calls[1].init.redirect).toBe('error')
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps waiting through a transient network error', async () => {
|
||||
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('reports expired on 401 and stops asking the server', async () => {
|
||||
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('expires by the clock without a request', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
h.clock.now += 600_000
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: '',
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
||||
profile: 'work',
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: 'account/model-a',
|
||||
models: ['account/model-a', 'account/model-b'],
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
||||
// No key ever rides a URL.
|
||||
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ask for the one-time keys again on later polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const first = await h.login.poll()
|
||||
const fetches = h.fetch.mock.calls.length
|
||||
const second = await h.login.poll()
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(h.fetch.mock.calls.length).toBe(fetches)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares one completion between overlapping polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
||||
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'ok'],
|
||||
[2, 'outdated'],
|
||||
[3, 'other-account'],
|
||||
[4, 'unknown-account'],
|
||||
[1, 'failed'],
|
||||
[null, 'failed'],
|
||||
['missing', 'missing']
|
||||
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
||||
})
|
||||
|
||||
it('treats a throwing CLI runner as failed without leaking', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
||||
})
|
||||
|
||||
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invent a model when the catalog is empty', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: true, reachable: true, models: [], message: '' }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never surfaces backend error text (which could echo the key)', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an approval whose key is malformed', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: other CLI account', () => {
|
||||
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
||||
await h.login.start()
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
it('overwrites only after the user confirms, without the keep flag', async () => {
|
||||
const h = await otherAccount()
|
||||
|
||||
const result = await h.login.overwriteCli()
|
||||
expect(result).toEqual({ cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
||||
|
||||
// Single use: the held key is gone.
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports an overwrite that still fails', async () => {
|
||||
const h = await otherAccount(4)
|
||||
|
||||
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
||||
})
|
||||
|
||||
it('drops the held CLI key after five minutes', async () => {
|
||||
const h = await otherAccount()
|
||||
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
||||
expect(hold).toBeDefined()
|
||||
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
hold?.fn()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a stale held key even if the timer never fired', async () => {
|
||||
const h = await otherAccount()
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the held CLI key on cancel', async () => {
|
||||
const h = await otherAccount()
|
||||
await h.login.cancel()
|
||||
|
||||
const error = await h.login.overwriteCli().catch(e => e)
|
||||
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
await h.login.poll()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: cancel', () => {
|
||||
it('revokes the code on the server and clears the session', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
const last = h.calls.at(-1)
|
||||
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('is a no-op without a login and survives a failing server', async () => {
|
||||
const h = harness()
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
expect(h.fetch).not.toHaveBeenCalled()
|
||||
|
||||
await h.login.start()
|
||||
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk CLI runner', () => {
|
||||
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
||||
|
||||
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
||||
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
||||
|
||||
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdin = new EventEmitter()
|
||||
|
||||
child.stdin.end = (chunk: string) => {
|
||||
state.stdin.push(chunk)
|
||||
queueMicrotask(() => behaviour(child))
|
||||
}
|
||||
|
||||
child.kill = () => {
|
||||
state.killed = true
|
||||
}
|
||||
|
||||
state.calls.push({ command, args, options })
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
return { spawn, state }
|
||||
}
|
||||
|
||||
it('finds the CLI only at the fixed install path', () => {
|
||||
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reports missing without spawning anything', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
||||
|
||||
const { command, args, options } = state.calls[0]
|
||||
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
expect(leaks([command, args, options])).toBe(false)
|
||||
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
||||
})
|
||||
|
||||
it('drops the keep flag for a confirmed overwrite', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
await run(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
||||
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
||||
})
|
||||
|
||||
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
||||
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const thrower = vi.fn(() => {
|
||||
throw new Error('spawn EINVAL')
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const epipe = fakeSpawn(child => {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
child.emit('close', 2)
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('kills a CLI that hangs past the timeout', async () => {
|
||||
const { spawn, state } = fakeSpawn(() => {})
|
||||
|
||||
let fire = () => {}
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env,
|
||||
platform: 'win32',
|
||||
setTimer: (fn, ms) => {
|
||||
expect(ms).toBe(60_000)
|
||||
fire = fn
|
||||
|
||||
return 1
|
||||
},
|
||||
clearTimer: () => {}
|
||||
})
|
||||
|
||||
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
||||
fire()
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(state.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an install path cmd.exe could reinterpret', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* aiturk-login.ts
|
||||
*
|
||||
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||||
*
|
||||
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||||
* page, and once the user approves, the server hands out two API keys exactly
|
||||
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||||
* process: they are not returned to the renderer, not logged, not put into
|
||||
* error messages, process arguments or URLs.
|
||||
* - The CLI receives its key on stdin only, is launched from an absolute
|
||||
* path without a shell, with a fixed argument list.
|
||||
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||||
* time out after 20 s.
|
||||
*
|
||||
* All I/O is injected so the logic is unit-testable without Electron, a
|
||||
* network or a real CLI (see aiturk-login.test.ts).
|
||||
*/
|
||||
|
||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
const AITURK_PROVIDER_ID = 'turkservis'
|
||||
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||||
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
|
||||
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||||
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||||
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||||
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||||
const MAX_EXPIRES_IN_S = 3600
|
||||
|
||||
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export type AiturkLoginErrorCode =
|
||||
| 'busy'
|
||||
| 'rate-limited'
|
||||
| 'unavailable'
|
||||
| 'invalid-response'
|
||||
| 'browser-failed'
|
||||
| 'provider-failed'
|
||||
| 'no-models'
|
||||
| 'nothing-to-overwrite'
|
||||
|
||||
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||||
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||||
export class AiturkLoginError extends Error {
|
||||
code: AiturkLoginErrorCode
|
||||
|
||||
constructor(code: AiturkLoginErrorCode) {
|
||||
super(`aiturk-login:${code}`)
|
||||
this.name = 'AiturkLoginError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiturkLoginScope {
|
||||
profile?: null | string
|
||||
connectionId?: null | string
|
||||
}
|
||||
|
||||
export interface AiturkLoginStartResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPollResult =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliResult }
|
||||
| { state: 'error'; error: AiturkLoginErrorCode }
|
||||
|
||||
export interface AiturkFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type AiturkFetch = (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||||
) => Promise<AiturkFetchResponse>
|
||||
|
||||
export interface AiturkBackendRequest {
|
||||
path: string
|
||||
method: string
|
||||
body: unknown
|
||||
profile?: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||||
* numeric exit code otherwise, null when it could not run / timed out. */
|
||||
export type AiturkCliExit = 'missing' | null | number
|
||||
|
||||
export interface AiturkLoginDeps {
|
||||
fetch: AiturkFetch
|
||||
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||||
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||||
hostname: () => string
|
||||
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||||
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||||
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||||
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||||
now: () => number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||||
}
|
||||
|
||||
interface Session {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
scope: { profile?: string; connectionId?: string }
|
||||
}
|
||||
|
||||
function deviceName(raw: unknown): string {
|
||||
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||||
|
||||
return cleaned || 'AITURK IDE'
|
||||
}
|
||||
|
||||
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||||
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||||
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||||
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||||
|
||||
return {
|
||||
...(profile ? { profile } : {}),
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||||
if (exit === 'missing') {
|
||||
return 'missing'
|
||||
}
|
||||
|
||||
if (exit === 0) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
if (exit === 2) {
|
||||
return 'outdated'
|
||||
}
|
||||
|
||||
if (exit === 3 && keepOtherAccount) {
|
||||
return 'other-account'
|
||||
}
|
||||
|
||||
if (exit === 4) {
|
||||
return 'unknown-account'
|
||||
}
|
||||
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
|
||||
const timeoutSignal =
|
||||
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||||
|
||||
let session: null | Session = null
|
||||
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||||
// polls share it so the one-time key is fetched and consumed exactly once.
|
||||
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||||
// Final outcome of the last login, replayed to later polls without a fetch.
|
||||
let settled: null | AiturkLoginPollResult = null
|
||||
// CLI key kept ONLY while the user decides whether to replace another
|
||||
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||||
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||||
|
||||
function dropHeldCli() {
|
||||
if (heldCli) {
|
||||
clearTimer(heldCli.timer)
|
||||
heldCli = null
|
||||
}
|
||||
}
|
||||
|
||||
function holdCli(key: string) {
|
||||
dropHeldCli()
|
||||
|
||||
const timer = setTimer(() => {
|
||||
heldCli = null
|
||||
}, AITURK_CLI_KEY_HOLD_MS)
|
||||
|
||||
// Never keep the app alive just for this timer.
|
||||
;(timer as { unref?: () => void } | null)?.unref?.()
|
||||
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||||
}
|
||||
|
||||
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||||
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
redirect: 'error',
|
||||
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelOnServer(deviceCode: string) {
|
||||
try {
|
||||
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||||
} catch {
|
||||
// Best effort: the code expires on its own within 10 minutes.
|
||||
}
|
||||
}
|
||||
|
||||
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||||
if (finishing) {
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
// One login at a time: a new start replaces (and revokes) the previous one.
|
||||
const previous = session
|
||||
session = null
|
||||
settled = null
|
||||
dropHeldCli()
|
||||
|
||||
if (previous) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new AiturkLoginError('rate-limited')
|
||||
}
|
||||
|
||||
if (response.status !== 201 && response.status !== 200) {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||||
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||||
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||||
const expiresIn = Number(data?.expiresIn)
|
||||
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||||
|
||||
if (
|
||||
!codeOk ||
|
||||
!USER_CODE_RE.test(userCode) ||
|
||||
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
// Never open a URL that is not exactly our approval page.
|
||||
if (codeOk) {
|
||||
await cancelOnServer(deviceCode)
|
||||
}
|
||||
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
if (session || finishing) {
|
||||
// A concurrent start won the race while we were waiting on the network.
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||||
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||||
|
||||
let opened: unknown
|
||||
|
||||
try {
|
||||
opened = await deps.openExternal(verificationUri)
|
||||
} catch {
|
||||
opened = false
|
||||
}
|
||||
|
||||
if (opened === false) {
|
||||
session = null
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('browser-failed')
|
||||
}
|
||||
|
||||
return { userCode, verificationUri, expiresAt }
|
||||
}
|
||||
|
||||
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||||
const endpoint = {
|
||||
id: AITURK_PROVIDER_ID,
|
||||
name: AITURK_PROVIDER_NAME,
|
||||
base_url: AITURK_PROVIDER_BASE_URL,
|
||||
api_key: apiKey,
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
|
||||
let checked: any
|
||||
|
||||
try {
|
||||
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||||
// saveCustomEndpoint (profile-scoped) calls.
|
||||
checked = await deps.backendRequest({
|
||||
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: '' }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (!checked?.ok) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||||
|
||||
if (!models.length) {
|
||||
throw new AiturkLoginError('no-models')
|
||||
}
|
||||
|
||||
let saved: any
|
||||
|
||||
try {
|
||||
saved = await deps.backendRequest({
|
||||
...scope,
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: models[0], models }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (saved?.ok === false) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||||
let exit: AiturkCliExit
|
||||
|
||||
try {
|
||||
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||||
} catch {
|
||||
exit = null
|
||||
}
|
||||
|
||||
return cliResultFromExit(exit, keepOtherAccount)
|
||||
}
|
||||
|
||||
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||||
try {
|
||||
await saveProvider(key, scope)
|
||||
} catch (error) {
|
||||
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||||
}
|
||||
|
||||
let cli: AiturkCliResult = 'failed'
|
||||
|
||||
if (cliKey) {
|
||||
cli = await handCliKey(cliKey, true)
|
||||
|
||||
if (cli === 'other-account') {
|
||||
holdCli(cliKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { state: 'connected', cli }
|
||||
}
|
||||
|
||||
async function poll(): Promise<AiturkLoginPollResult> {
|
||||
if (finishing) {
|
||||
return finishing
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
return settled
|
||||
}
|
||||
|
||||
const current = session
|
||||
|
||||
if (!current) {
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (deps.now() >= current.expiresAt) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||||
} catch {
|
||||
// Transient network trouble: keep waiting until the code expires.
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
// Cancelled or replaced while the request was in flight.
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (data?.state !== 'approved') {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
// The server returns the keys exactly once; the device code is spent.
|
||||
session = null
|
||||
|
||||
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||||
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||||
|
||||
if (!key) {
|
||||
settled = { state: 'error', error: 'invalid-response' }
|
||||
|
||||
return settled
|
||||
}
|
||||
|
||||
finishing = finish(key, cliKey, current.scope)
|
||||
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||||
.then(result => {
|
||||
settled = result
|
||||
finishing = null
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return finishing
|
||||
}
|
||||
|
||||
async function cancel(): Promise<{ ok: true }> {
|
||||
const current = session
|
||||
session = null
|
||||
dropHeldCli()
|
||||
|
||||
if (!finishing) {
|
||||
settled = null
|
||||
}
|
||||
|
||||
if (current) {
|
||||
await cancelOnServer(current.deviceCode)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||||
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||||
const held = heldCli
|
||||
|
||||
if (!held || deps.now() >= held.expiresAt) {
|
||||
dropHeldCli()
|
||||
|
||||
throw new AiturkLoginError('nothing-to-overwrite')
|
||||
}
|
||||
|
||||
// Single use: drop it before launching so it cannot be replayed.
|
||||
dropHeldCli()
|
||||
|
||||
const cli = await handCliKey(held.key, false)
|
||||
|
||||
if (settled?.state === 'connected') {
|
||||
settled = { state: 'connected', cli }
|
||||
}
|
||||
|
||||
return { cli }
|
||||
}
|
||||
|
||||
return { start, poll, cancel, overwriteCli }
|
||||
}
|
||||
|
||||
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiturkCliRunnerDeps {
|
||||
/** child_process.spawn (injected for tests). */
|
||||
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||||
existsSync: (path: string) => boolean
|
||||
env: Record<string, string | undefined>
|
||||
platform?: string
|
||||
timeoutMs?: number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
}
|
||||
|
||||
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||||
// carries either (or a control char) is refused rather than risk reparsing.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||||
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||||
|
||||
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||||
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||||
|
||||
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||||
|
||||
try {
|
||||
return deps.existsSync(candidate) ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||||
* stdin and nowhere else: the argument list is a fixed array with no user
|
||||
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||||
*/
|
||||
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||||
|
||||
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||||
const cliPath = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cliPath) {
|
||||
return Promise.resolve('missing')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||||
|
||||
// A .cmd cannot be spawned directly without a shell; run it through an
|
||||
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||||
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||||
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false
|
||||
let timer: unknown = null
|
||||
let child: any
|
||||
|
||||
const settle = (value: AiturkCliExit) => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
|
||||
if (timer !== null) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
} catch {
|
||||
settle(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
|
||||
settle(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('error', () => settle(null))
|
||||
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||||
|
||||
try {
|
||||
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||||
// the exit code is the answer.
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.end(`${cliKey}\n`)
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -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,
|
||||
@@ -31,8 +32,18 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
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'
|
||||
@@ -17231,6 +17242,53 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── AITURK one-click sign-in (device code) ───────────────────────────────
|
||||
// The API keys returned by turkservis.online stay in THIS process: the
|
||||
// provider is saved through the same backend route hermes:api uses and the
|
||||
// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees
|
||||
// the short user code, the approval URL and a status. See aiturk-login.ts.
|
||||
const aiturkLogin = createAiturkLogin({
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
openExternal: url => openExternalUrl(url),
|
||||
hostname: () => os.hostname(),
|
||||
backendRequest: request => handleHermesApiRequest(request),
|
||||
runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }),
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) {
|
||||
throw new Error('aiturk-login:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:login:start', async (event, scope) => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.start(scope)
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:poll', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.poll()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:cancel', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.cancel()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:overwrite-cli', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.overwriteCli()
|
||||
})
|
||||
|
||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||
@@ -17770,19 +17828,107 @@ 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,
|
||||
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||
run: (file, args) =>
|
||||
new Promise(resolve => {
|
||||
execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => {
|
||||
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0
|
||||
|
||||
resolve({ code, stdout: String(stdout ?? '') })
|
||||
})
|
||||
})
|
||||
}),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||
WebSocket: RelayWebSocket,
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^relay /.test(line) || / \/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'),
|
||||
|
||||
@@ -1,94 +1,166 @@
|
||||
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: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||
toggle: 'Telefon bağlantısını kullan',
|
||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
|
||||
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||
errors: {
|
||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||
toggle: 'Use phone connection',
|
||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
|
||||
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||
errors: {
|
||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||
not_enabled: 'Turn phone connection on first.',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||
failed: 'That did not work. Try again.',
|
||||
},
|
||||
},
|
||||
}
|
||||
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-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
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
|
||||
$('remote-box').hidden = !state.remote
|
||||
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||
$('status-box').hidden = !state.enabled || state.remote
|
||||
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||
const noRule = lan.direct && lan.firewall === 'missing'
|
||||
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||
$('lan-disallow').hidden = !lan.direct
|
||||
$('pair-section').hidden = !state.enabled || state.remote
|
||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||
if (state.pairing) {
|
||||
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]
|
||||
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||
tick()
|
||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||
$('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.enabled || state.remote
|
||||
$('relay-renew').hidden = !relay.ready
|
||||
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||
}
|
||||
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')) })
|
||||
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||
setInterval(tick, 1000)
|
||||
// "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()
|
||||
@@ -0,0 +1,26 @@
|
||||
/* "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 }
|
||||
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||
.status-actions button { margin:0; min-height:44px }
|
||||
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||
footer button.link { min-height:44px }
|
||||
@@ -1,24 +1,33 @@
|
||||
<!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="status-box" hidden>
|
||||
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||
</section>
|
||||
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></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="pair-security" class="security"></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><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
@@ -1,157 +1,50 @@
|
||||
// "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),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
'lan.allow': () => link.lanAllow(),
|
||||
'lan.disallow': () => link.lanDisallow(),
|
||||
'firewall.allow': () => link.lanAllow(),
|
||||
'firewall.retry': () => link.lanAllow(),
|
||||
'relay.renew': () => link.relayRenew(),
|
||||
'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')
|
||||
})
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* mobile-relay-client.ts
|
||||
*
|
||||
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||
*
|
||||
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||
* controller keeps it in the safeStorage vault). It is sent in the
|
||||
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||
* log line, a status object or an error message.
|
||||
* - Log lines are status changes only.
|
||||
* - Errors are fixed codes.
|
||||
*
|
||||
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||
* relay on 127.0.0.1 and a fake clock.
|
||||
*/
|
||||
|
||||
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||
export const RELAY_SILENCE_MS = 75_000
|
||||
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||
export const RELAY_MAX_STREAMS = 16
|
||||
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||
|
||||
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||
const BUFFER_POLL_MS = 25
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||
|
||||
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||
export function isRelayId(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value)
|
||||
}
|
||||
|
||||
export interface RelayCredentials {
|
||||
relayId: string
|
||||
hostToken: string
|
||||
}
|
||||
|
||||
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||
const row = value as null | RelayCredentials
|
||||
|
||||
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||
}
|
||||
|
||||
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||
|
||||
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||
}
|
||||
|
||||
export interface RelayFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||
|
||||
export type RelayFailureCode =
|
||||
| 'register_failed'
|
||||
| 'rate_limited'
|
||||
| 'vault_unavailable'
|
||||
| 'connect_failed'
|
||||
| 'token_rejected'
|
||||
| 'replaced'
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** `POST /sessions`; a fixed failure code instead of any upstream text. */
|
||||
export async function registerRelaySession(fetcher: RelayFetch): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
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)
|
||||
})
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the client needs from the outside ──────────────────────────────────
|
||||
|
||||
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||
export interface RelaySocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
/** A string is a text frame, a Buffer a binary frame. */
|
||||
send: (data: Buffer | string) => unknown
|
||||
close: (code?: number) => unknown
|
||||
terminate?: () => unknown
|
||||
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||
pause?: () => unknown
|
||||
resume?: () => unknown
|
||||
readonly bufferedAmount?: number
|
||||
}
|
||||
|
||||
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||
|
||||
export interface RelayWebSocketCtor {
|
||||
new (url: string, options?: Record<string, unknown>): any
|
||||
}
|
||||
|
||||
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||
export interface RelayLocalSocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
write: (data: Buffer) => boolean
|
||||
destroy: () => unknown
|
||||
pause: () => unknown
|
||||
resume: () => unknown
|
||||
readonly writableLength?: number
|
||||
}
|
||||
|
||||
/** null = the proxy is not listening right now. */
|
||||
export type RelayDial = () => null | RelayLocalSocket
|
||||
|
||||
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||
|
||||
frame.writeUInt32BE(id, 0)
|
||||
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
|
||||
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||
parts.push(chunk.subarray(offset, offset + size))
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||
|
||||
// ── The client ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||
|
||||
export interface RelayState {
|
||||
status: RelayStatus
|
||||
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||
code: null | RelayFailureCode
|
||||
}
|
||||
|
||||
export interface RelayCredentialStore {
|
||||
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||
load: () => Promise<null | RelayCredentials>
|
||||
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface RelayClientDeps {
|
||||
connect: RelayConnect
|
||||
fetch: RelayFetch
|
||||
dial: RelayDial
|
||||
credentials: RelayCredentialStore
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
/** Status changes only. */
|
||||
log?: (line: string) => void
|
||||
onState?: (state: RelayState) => void
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
local: RelayLocalSocket
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export function createRelayClient(deps: RelayClientDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||
|
||||
let state: RelayState = { status: 'off', code: null }
|
||||
let wanted = false
|
||||
let generation = 0
|
||||
let attempt = 0
|
||||
let socket: null | RelaySocket = null
|
||||
let credentials: null | RelayCredentials = null
|
||||
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||
let rejected = false
|
||||
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||
let retryTimer: unknown = null
|
||||
let silenceTimer: unknown = null
|
||||
const streams = new Map<number, Stream>()
|
||||
|
||||
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||
if (state.status === status && state.code === code) {
|
||||
return
|
||||
}
|
||||
|
||||
state = { status, code }
|
||||
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||
|
||||
try {
|
||||
deps.onState?.(state)
|
||||
} catch {
|
||||
// a broken listener must not break the relay
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (retryTimer !== null) {
|
||||
clearTimer(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
silenceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored registration, or a new one. One request at a time. */
|
||||
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
if (credentials) {
|
||||
return Promise.resolve(credentials)
|
||||
}
|
||||
|
||||
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||
let stored: null | RelayCredentials
|
||||
|
||||
try {
|
||||
stored = await deps.credentials.load()
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
if (isRelayCredentials(stored)) {
|
||||
credentials = stored
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: rejected })
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
rejected = false
|
||||
credentials = fresh
|
||||
|
||||
return fresh
|
||||
})().finally(() => {
|
||||
registering = null
|
||||
})
|
||||
|
||||
return registering
|
||||
}
|
||||
|
||||
function dropStreams() {
|
||||
for (const stream of streams.values()) {
|
||||
stream.local.destroy()
|
||||
}
|
||||
|
||||
streams.clear()
|
||||
}
|
||||
|
||||
function retry(code: RelayFailureCode, delay?: number) {
|
||||
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||
|
||||
attempt++
|
||||
setState('failed', code)
|
||||
retryTimer = setTimer(() => {
|
||||
retryTimer = null
|
||||
void connect()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!wanted || socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const mine = ++generation
|
||||
|
||||
clearTimers()
|
||||
setState('connecting')
|
||||
|
||||
const current = await register()
|
||||
|
||||
if (!wanted || mine !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('error' in current) {
|
||||
retry(current.error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let ws: RelaySocket
|
||||
|
||||
try {
|
||||
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||
} catch {
|
||||
retry('connect_failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket = ws
|
||||
|
||||
const send = (data: Buffer | string) => {
|
||||
if (socket === ws) {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
// the close event follows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||
|
||||
const watch = () => {
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
}
|
||||
|
||||
silenceTimer = setTimer(() => {
|
||||
silenceTimer = null
|
||||
|
||||
if (socket === ws) {
|
||||
try {
|
||||
;(ws.terminate ?? ws.close).call(ws)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
|
||||
down(1006, 'timeout')
|
||||
}
|
||||
}, RELAY_SILENCE_MS)
|
||||
}
|
||||
|
||||
const down = (code: number, reason?: RelayFailureCode) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
socket = null
|
||||
clearTimers()
|
||||
dropStreams()
|
||||
|
||||
if (!wanted) {
|
||||
setState('off')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||
if (code === 4401 || code === 4404) {
|
||||
credentials = null
|
||||
rejected = true
|
||||
void deps.credentials
|
||||
.clear()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||
retry('token_rejected')
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 4409) {
|
||||
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4429: the relay service is full; the normal growing delay applies.
|
||||
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||
}
|
||||
|
||||
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||
const stalled = new Set<Stream>()
|
||||
|
||||
const unstall = (stream: Stream) => {
|
||||
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||
ws.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||
if (streams.get(id) !== stream) {
|
||||
return
|
||||
}
|
||||
|
||||
streams.delete(id)
|
||||
unstall(stream)
|
||||
stream.local.destroy()
|
||||
|
||||
if (tell) {
|
||||
control({ t: 'close', id, code })
|
||||
}
|
||||
}
|
||||
|
||||
const openStream = (id: number) => {
|
||||
if (streams.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||
return control({ t: 'close', id, code: 4429 })
|
||||
}
|
||||
|
||||
let local: null | RelayLocalSocket
|
||||
|
||||
try {
|
||||
local = deps.dial()
|
||||
} catch {
|
||||
local = null
|
||||
}
|
||||
|
||||
if (!local) {
|
||||
return control({ t: 'close', id, code: 4503 })
|
||||
}
|
||||
|
||||
const stream: Stream = { local, paused: false }
|
||||
let failed = false
|
||||
|
||||
const live = () => streams.get(id) === stream
|
||||
|
||||
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||
const drain = () => {
|
||||
if (!live() || socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stream.paused = false
|
||||
local.resume()
|
||||
}
|
||||
|
||||
streams.set(id, stream)
|
||||
local.on('connect', () => {
|
||||
if (live()) {
|
||||
control({ t: 'opened', id })
|
||||
}
|
||||
})
|
||||
local.on('data', (chunk: Buffer) => {
|
||||
if (!live()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||
for (const part of splitRelayChunks(chunk)) {
|
||||
send(encodeRelayFrame(id, part))
|
||||
}
|
||||
|
||||
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
stream.paused = true
|
||||
local.pause()
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
}
|
||||
})
|
||||
local.on('drain', () => unstall(stream))
|
||||
local.on('error', () => {
|
||||
failed = true
|
||||
})
|
||||
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||
}
|
||||
|
||||
ws.on('open', () => {
|
||||
if (socket === ws) {
|
||||
attempt = 0
|
||||
setState('connected')
|
||||
watch()
|
||||
}
|
||||
})
|
||||
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||
ws.on('error', () => down(1006))
|
||||
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||
const status = Number(res?.statusCode)
|
||||
|
||||
res?.resume?.()
|
||||
req?.destroy?.()
|
||||
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||
})
|
||||
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
watch()
|
||||
|
||||
if (isBinary) {
|
||||
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||
|
||||
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = frame.readUInt32BE(0)
|
||||
const stream = streams.get(id)
|
||||
|
||||
// Late or unknown ids are ignored.
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||
|
||||
const pending = stream.local.writableLength ?? 0
|
||||
|
||||
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
endStream(id, stream, 4408, true)
|
||||
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||
stalled.add(stream)
|
||||
ws.pause()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let message: any
|
||||
|
||||
try {
|
||||
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'ping') {
|
||||
return control({ t: 'pong' })
|
||||
}
|
||||
|
||||
if (!validStreamId(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'open') {
|
||||
return openStream(message.id)
|
||||
}
|
||||
|
||||
if (message.t === 'close') {
|
||||
const stream = streams.get(message.id)
|
||||
|
||||
if (stream) {
|
||||
endStream(message.id, stream, 1000, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const api = {
|
||||
state: (): RelayState => state,
|
||||
|
||||
/** Open phone streams right now. */
|
||||
streams: (): number => streams.size,
|
||||
|
||||
/**
|
||||
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||
*/
|
||||
async ensureRegistered(): Promise<null | string> {
|
||||
const current = await register()
|
||||
|
||||
return 'error' in current ? null : current.relayId
|
||||
},
|
||||
|
||||
/**
|
||||
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
credentials = fresh
|
||||
rejected = false
|
||||
|
||||
if (wanted) {
|
||||
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||
api.stop()
|
||||
api.start()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
start() {
|
||||
if (wanted) {
|
||||
return
|
||||
}
|
||||
|
||||
wanted = true
|
||||
attempt = 0
|
||||
void connect()
|
||||
},
|
||||
|
||||
stop() {
|
||||
wanted = false
|
||||
generation++
|
||||
clearTimers()
|
||||
|
||||
const ws = socket
|
||||
|
||||
socket = null
|
||||
dropStreams()
|
||||
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
setState('off')
|
||||
}
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||
@@ -278,6 +278,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
aiturkLogin: {
|
||||
// One-click TurkServis sign-in. API keys never cross this bridge: main
|
||||
// saves the provider and hands the CLI its key (electron/aiturk-login.ts).
|
||||
start: scope => ipcRenderer.invoke('aiturk:login:start', scope),
|
||||
poll: () => ipcRenderer.invoke('aiturk:login:poll'),
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.12",
|
||||
"version": "1.0.0-beta.16",
|
||||
"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'))
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
||||
|
||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const c = useAiturkCopy().custom
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const c = aiturkCopy.custom
|
||||
const loginCopy = aiturkCopy.login
|
||||
const loginLabel = loginCopy.settingsSignIn
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -129,6 +133,31 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
}
|
||||
}, [])
|
||||
|
||||
// One-click sign-in saved the TurkServis provider in the main process:
|
||||
// reload the list and tell the app its default model changed.
|
||||
async function handleAiturkConnected() {
|
||||
try {
|
||||
const data = await getCustomEndpoints()
|
||||
setEndpoints(data.endpoints)
|
||||
const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis')
|
||||
|
||||
if (current) {
|
||||
setForm(formFromEndpoint(current))
|
||||
setDiscoveredModels(current.models)
|
||||
|
||||
if (current.is_current) {
|
||||
onMainModelChanged?.(current.id, current.model)
|
||||
}
|
||||
}
|
||||
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', message: loginCopy.connected })
|
||||
} catch (err) {
|
||||
notifyError(err, c.loadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Button className="mb-3" onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
||||
<Button onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
</div>
|
||||
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
||||
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AiturkCliStatus } from '@/global'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { aiturkLoginScope } from '@/lib/aiturk-provider'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing'
|
||||
|
||||
interface Waiting {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
}
|
||||
|
||||
type LoginCopy = ReturnType<typeof useAiturkCopy>
|
||||
|
||||
function errorCode(error: unknown): string {
|
||||
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
||||
return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? ''
|
||||
}
|
||||
|
||||
function errorText(code: string, copy: LoginCopy): string {
|
||||
switch (code) {
|
||||
case 'rate-limited':
|
||||
return copy.login.rateLimited
|
||||
|
||||
case 'browser-failed':
|
||||
return copy.login.browserFailed
|
||||
|
||||
case 'no-models':
|
||||
return copy.noModels
|
||||
|
||||
case 'provider-failed':
|
||||
|
||||
case 'invalid-response':
|
||||
return copy.failed
|
||||
|
||||
default:
|
||||
return copy.login.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function cliText(cli: AiturkCliStatus, copy: LoginCopy): string {
|
||||
switch (cli) {
|
||||
case 'ok':
|
||||
return copy.login.cliOk
|
||||
|
||||
case 'outdated':
|
||||
return copy.login.cliOutdated
|
||||
|
||||
case 'failed':
|
||||
|
||||
case 'unknown-account':
|
||||
return copy.login.cliFailed
|
||||
|
||||
default:
|
||||
// 'missing' stays silent; 'other-account' is asked as a question.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click TurkServis sign-in, shared by onboarding and Settings.
|
||||
*
|
||||
* The API keys never reach this component: the main process shows us only the
|
||||
* short code, then reports `connected` once it has saved the provider and
|
||||
* handed the AITURK CLI its own key.
|
||||
*/
|
||||
export function useAiturkLogin(onConnected: () => Promise<void> | void) {
|
||||
const copy = useAiturkCopy()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [waiting, setWaiting] = useState<null | Waiting>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const live = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const onConnectedRef = useRef(onConnected)
|
||||
const copyRef = useRef(copy)
|
||||
|
||||
onConnectedRef.current = onConnected
|
||||
copyRef.current = copy
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
|
||||
// Leaving mid-login: revoke the code and drop anything main still holds.
|
||||
if (live.current) {
|
||||
live.current = false
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const finish = useCallback(async (cli: AiturkCliStatus) => {
|
||||
live.current = false
|
||||
const text = cliText(cli, copyRef.current)
|
||||
|
||||
if (mounted.current) {
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setInfo(text)
|
||||
}
|
||||
|
||||
try {
|
||||
await onConnectedRef.current()
|
||||
} catch {
|
||||
// The provider is saved; a failed refresh is the caller's concern.
|
||||
}
|
||||
|
||||
// Onboarding closes this panel as soon as it is connected; keep the CLI
|
||||
// note visible as a toast in that case.
|
||||
if (!mounted.current && text) {
|
||||
notify({ kind: cli === 'ok' ? 'success' : 'info', message: text })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends
|
||||
useEffect(() => {
|
||||
if (phase !== 'waiting') {
|
||||
return
|
||||
}
|
||||
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const tick = async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
if (stopped || !bridge) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await bridge.poll()
|
||||
} catch {
|
||||
result = { state: 'pending' as const }
|
||||
}
|
||||
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'pending') {
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'connected') {
|
||||
if (result.cli === 'other-account') {
|
||||
setWaiting(null)
|
||||
setPhase('question')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await finish(result.cli)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current))
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [phase, finish])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
setError('')
|
||||
setInfo('')
|
||||
|
||||
if (!bridge) {
|
||||
setError(copyRef.current.login.unsupported)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('starting')
|
||||
|
||||
try {
|
||||
const started = await bridge.start(aiturkLoginScope())
|
||||
|
||||
if (!mounted.current) {
|
||||
void bridge.cancel().catch(() => {})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = true
|
||||
setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri })
|
||||
setPhase('waiting')
|
||||
} catch (err) {
|
||||
if (mounted.current) {
|
||||
setPhase('idle')
|
||||
setError(errorText(errorCode(err), copyRef.current))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const reopen = useCallback(() => {
|
||||
if (waiting) {
|
||||
void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {})
|
||||
}
|
||||
}, [waiting])
|
||||
|
||||
const replaceCli = useCallback(async () => {
|
||||
setPhase('replacing')
|
||||
|
||||
let cli: AiturkCliStatus = 'failed'
|
||||
|
||||
try {
|
||||
cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed'
|
||||
} catch {
|
||||
cli = 'failed'
|
||||
}
|
||||
|
||||
await finish(cli === 'other-account' ? 'failed' : cli)
|
||||
}, [finish])
|
||||
|
||||
const keepCli = useCallback(async () => {
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
await finish('missing')
|
||||
}, [finish])
|
||||
|
||||
return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli }
|
||||
}
|
||||
|
||||
interface AiturkLoginProps {
|
||||
/** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */
|
||||
onConnected: () => Promise<void> | void
|
||||
/** Idle button label; defaults to "Giriş yap". */
|
||||
label?: string
|
||||
variant?: 'default' | 'outline'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) {
|
||||
const copy = useAiturkCopy()
|
||||
const login = useAiturkLogin(onConnected)
|
||||
|
||||
if (login.phase === 'waiting' && login.waiting) {
|
||||
return (
|
||||
<div className="grid gap-3" data-testid="aiturk-login-code">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
||||
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
||||
{login.waiting.userCode}
|
||||
</output>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={login.reopen} size="sm" variant="outline">
|
||||
{copy.login.reopen}
|
||||
</Button>
|
||||
<Button onClick={login.cancel} size="sm" variant="ghost">
|
||||
{copy.login.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (login.phase === 'question' || login.phase === 'replacing') {
|
||||
const replacing = login.phase === 'replacing'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="group">
|
||||
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
||||
{replacing ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.login.cliReplace}
|
||||
</Button>
|
||||
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
||||
{copy.login.cliKeep}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const starting = login.phase === 'starting'
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
||||
{starting ? <Loader2 className="animate-spin" /> : null}
|
||||
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
||||
</Button>
|
||||
</div>
|
||||
{login.error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{login.error}
|
||||
</p>
|
||||
) : null}
|
||||
{login.info ? (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{login.info}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -17,6 +22,7 @@ function setup() {
|
||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
return { input, onConnected }
|
||||
}
|
||||
|
||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
function setupLogin(polls: unknown[]) {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const bridge = {
|
||||
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||
}
|
||||
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||
|
||||
return { bridge, onConnected, openExternal }
|
||||
}
|
||||
|
||||
const click = async (name: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
describe('TurkServis one-click sign-in', () => {
|
||||
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||
await click('Giriş yap')
|
||||
|
||||
expect(bridge.start).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
|
||||
await click('Sayfayı yeniden aç')
|
||||
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
// No key ever reaches the renderer through this flow.
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', ''],
|
||||
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||
])('connects and explains CLI result %s', async (cli, text) => {
|
||||
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||
})
|
||||
|
||||
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
|
||||
await click('Değiştir')
|
||||
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
})
|
||||
|
||||
it('leaves the other CLI account alone when the user says so', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
await click('Olduğu gibi bırak')
|
||||
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
await click('Vazgeç')
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||
await tick()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an expired code and a failed start without connecting', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||
|
||||
bridge.start.mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||
)
|
||||
await click('Giriş yap')
|
||||
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes a pending login when the panel goes away', async () => {
|
||||
const { bridge } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
cleanup()
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still connects with a pasted API key', async () => {
|
||||
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function connect() {
|
||||
if (busy || !key.trim()) return
|
||||
if (busy || !key.trim()) {return}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Discovery goes through the connected backend's existing credential
|
||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
||||
|
||||
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
||||
const model = checked.models[0]
|
||||
if (!model) throw new Error(copy.noModels)
|
||||
|
||||
if (!model) {throw new Error(copy.noModels)}
|
||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||
if (saved.ok === false) throw new Error(copy.failed)
|
||||
|
||||
if (saved.ok === false) {throw new Error(copy.failed)}
|
||||
setKey('')
|
||||
await onConnected()
|
||||
} catch (err) {
|
||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||
</div>
|
||||
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||
<label className="grid gap-2 text-sm">
|
||||
{copy.key}
|
||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
||||
placeholder={copy.key} type="password" value={key} />
|
||||
</label>
|
||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Vendored
+26
@@ -323,6 +323,18 @@ declare global {
|
||||
setDisableF12?: (blocked: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
/** One-click TurkServis sign-in (electron/aiturk-login.ts). API keys stay
|
||||
* in the main process; only the short code and a status come back. */
|
||||
aiturkLogin?: {
|
||||
start: (scope?: { connectionId?: string; profile?: string }) => Promise<AiturkLoginStart>
|
||||
poll: () => Promise<AiturkLoginPoll>
|
||||
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,
|
||||
@@ -1212,6 +1224,20 @@ export type DesktopBootstrapEvent =
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
export type AiturkCliStatus = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export interface AiturkLoginStart {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPoll =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliStatus }
|
||||
| { state: 'error'; error: string }
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -30,6 +30,28 @@ const en = {
|
||||
getKey: 'My account and API keys',
|
||||
noModels: 'No models are available for this key.',
|
||||
failed: 'Could not connect to TurkServis.',
|
||||
login: {
|
||||
signIn: 'Sign in',
|
||||
settingsSignIn: 'Sign in to AITURK',
|
||||
starting: 'Starting sign-in…',
|
||||
codeLabel: 'Sign-in code',
|
||||
confirmHint: 'Approve if the code on the page opened in your browser matches this one.',
|
||||
reopen: 'Reopen the page',
|
||||
cancel: 'Cancel',
|
||||
orPasteKey: 'or paste an API key',
|
||||
connected: 'Your TurkServis account is connected.',
|
||||
expired: 'The sign-in code expired. Try again.',
|
||||
rateLimited: 'Too many attempts. Try again in a little while.',
|
||||
unavailable: 'Could not start sign-in. Check your internet connection and try again.',
|
||||
browserFailed: 'Could not open the browser. Try again.',
|
||||
unsupported: 'One-click sign-in is not available here. Paste an API key instead.',
|
||||
cliOk: 'AITURK CLI is ready with this account too.',
|
||||
cliOutdated: 'After AITURK CLI is updated, sign in again to connect it automatically.',
|
||||
cliOtherAccount: 'AITURK CLI has a different account saved. Replace it with this account?',
|
||||
cliReplace: 'Replace',
|
||||
cliKeep: 'Leave as is',
|
||||
cliFailed: 'AITURK CLI could not be connected; run `aiturk login` in a terminal.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Custom endpoints',
|
||||
active: 'Active',
|
||||
@@ -64,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',
|
||||
@@ -79,6 +105,28 @@ const tr: typeof en = {
|
||||
getKey: 'Hesabım ve API anahtarlarım',
|
||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
||||
login: {
|
||||
signIn: 'Giriş yap',
|
||||
settingsSignIn: 'AITURK’e giriş yap',
|
||||
starting: 'Giriş başlatılıyor…',
|
||||
codeLabel: 'Giriş kodu',
|
||||
confirmHint: 'Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.',
|
||||
reopen: 'Sayfayı yeniden aç',
|
||||
cancel: 'Vazgeç',
|
||||
orPasteKey: 'ya da API anahtarı yapıştırın',
|
||||
connected: 'TurkServis hesabınız bağlandı.',
|
||||
expired: 'Giriş kodunun süresi doldu. Yeniden deneyin.',
|
||||
rateLimited: 'Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.',
|
||||
unavailable: 'Giriş başlatılamadı. İnternet bağlantınızı kontrol edip yeniden deneyin.',
|
||||
browserFailed: 'Tarayıcı açılamadı. Yeniden deneyin.',
|
||||
unsupported: 'Tek tıkla giriş burada kullanılamıyor. API anahtarı yapıştırın.',
|
||||
cliOk: 'AITURK CLI de bu hesapla hazır.',
|
||||
cliOutdated: 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.',
|
||||
cliOtherAccount: 'AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?',
|
||||
cliReplace: 'Değiştir',
|
||||
cliKeep: 'Olduğu gibi bırak',
|
||||
cliFailed: 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Özel API bağlantıları',
|
||||
active: 'Etkin',
|
||||
@@ -113,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,8 +1,15 @@
|
||||
import { connectionScoped, profileScoped } from '@/api/client'
|
||||
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
||||
|
||||
/** Where the main process should save the provider after a one-click sign-in:
|
||||
* the same (connection, profile) the renderer's saveCustomEndpoint targets. */
|
||||
export function aiturkLoginScope(): { connectionId?: string; profile?: string } {
|
||||
return { ...connectionScoped(), ...profileScoped() }
|
||||
}
|
||||
|
||||
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||
return {
|
||||
id: 'turkservis',
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Connect the AITURK distribution's media tools to its existing member key."""
|
||||
|
||||
|
||||
def configure_existing_media(cfg: dict) -> bool:
|
||||
"""Migrate an existing member endpoint when the upgraded backend starts."""
|
||||
providers = cfg.get("providers") or {}
|
||||
entries = providers.values() if isinstance(providers, dict) else []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and configure_media(cfg, entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def configure_media(cfg: dict, entry: dict) -> bool:
|
||||
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
|
||||
return False
|
||||
key_env = entry.get("key_env")
|
||||
if not isinstance(key_env, str) or not key_env:
|
||||
return False
|
||||
servers = cfg.get("mcp_servers")
|
||||
if servers is None:
|
||||
servers = cfg["mcp_servers"] = {}
|
||||
if not isinstance(servers, dict):
|
||||
return False
|
||||
name = "turkservis-media"
|
||||
existing = servers.get(name, {})
|
||||
if not isinstance(existing, dict):
|
||||
return False
|
||||
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
|
||||
return False
|
||||
updated = {
|
||||
**existing,
|
||||
"url": "https://ai.turkservis.online/mcp",
|
||||
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
|
||||
"timeout": 300,
|
||||
"tools": {"include": ["list_capabilities", "generate_media"]},
|
||||
}
|
||||
if updated == existing:
|
||||
return False
|
||||
servers[name] = updated
|
||||
return True
|
||||
@@ -405,12 +405,6 @@ def _eager_reconcile_own_session_db() -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
from hermes_cli.aiturk_media import configure_existing_media
|
||||
from hermes_cli.config import read_raw_config, save_config
|
||||
with _CONFIG_MUTATION_LOCK:
|
||||
media_cfg = read_raw_config()
|
||||
if configure_existing_media(media_cfg):
|
||||
save_config(media_cfg)
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
app.state.pty_active_session_files = {} # dict[str, Path]
|
||||
@@ -8829,9 +8823,6 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
||||
providers[endpoint_id] = entry
|
||||
cfg["providers"] = providers
|
||||
|
||||
from hermes_cli.aiturk_media import configure_media
|
||||
configure_media(cfg, entry)
|
||||
|
||||
if body.make_default:
|
||||
cfg["model"] = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), endpoint_id, model, base_url
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.12",
|
||||
"version": "1.0.0-beta.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: aiturk-media
|
||||
description: "Generate images, video and speech or transcribe audio with the user's TurkServis account through the AITURK CLI (`aiturk media`). Use whenever the user asks for an image, video, voice-over or transcript."
|
||||
version: 1.0.0
|
||||
author: AITURK / TurkServis
|
||||
license: MIT
|
||||
platforms: [windows]
|
||||
prerequisites:
|
||||
commands: [aiturk]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Media, Image, Video, TTS, STT, AITURK, TurkServis]
|
||||
---
|
||||
|
||||
# AITURK media (image, video, speech, transcription)
|
||||
|
||||
AITURK IDE produces media with the AITURK CLI that is installed next to it. The
|
||||
CLI uses the same TurkServis account; usage is billed to the member's balance.
|
||||
Do not add or enable an MCP media server for this.
|
||||
|
||||
## When to use
|
||||
|
||||
The user asks for an image, a short video, spoken audio from text, or a
|
||||
transcript of an audio file.
|
||||
|
||||
## Steps
|
||||
|
||||
The launcher is `aiturk.cmd`. In PowerShell or cmd type `aiturk`; in a bash-style
|
||||
terminal (Git Bash) type `aiturk.cmd` instead, with the same arguments.
|
||||
|
||||
1. List what the account can use right now. Never rely on a remembered model list.
|
||||
|
||||
```shell
|
||||
aiturk media list --json
|
||||
```
|
||||
|
||||
Use a capability only when its `status` is `available`. `unknown` is an
|
||||
access error; `unavailable` means no usable model. Pick the model the user
|
||||
named, or a fitting `id` from the list. Never invent a model id.
|
||||
|
||||
2. Run one generation. Choose an output file name that does not exist yet.
|
||||
|
||||
```shell
|
||||
aiturk media run image --model <model-id> --text "<what the user asked for>" --output image.png
|
||||
aiturk media run video --model <model-id> --text "<prompt>" --seconds 5 --output clip.mp4
|
||||
aiturk media run tts --model <model-id> --text "<text>" --voice <voice> --output speech.mp3
|
||||
aiturk media run stt --model <model-id> --audio <audio-file> --output transcript.txt
|
||||
```
|
||||
|
||||
3. Confirm the output file exists and has the expected type, then show or link
|
||||
it to the user. Do not retry a failed or interrupted generation automatically;
|
||||
each run is billed.
|
||||
|
||||
## If the command is unavailable
|
||||
|
||||
- `aiturk` and `aiturk.cmd` both not found: try `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`.
|
||||
If that is missing too, tell the user to install the AITURK CLI from
|
||||
https://turkservis.online/indir.
|
||||
- Not signed in or an authentication error: ask the user to run `aiturk login`
|
||||
once, then `aiturk doctor`.
|
||||
- Only while the CLI cannot be used at all may an already configured TurkServis
|
||||
media connection be used instead; say that you did and why.
|
||||
|
||||
Never print API keys, tokens or authorization headers. Without a generation
|
||||
request, list only; do not start a paid trial generation.
|
||||
@@ -1,35 +1,52 @@
|
||||
from hermes_cli.aiturk_media import configure_media, configure_existing_media
|
||||
"""AITURK IDE produces media through the AITURK CLI, never by adding an MCP server."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL = ROOT / "skills" / "media" / "aiturk-media" / "SKILL.md"
|
||||
|
||||
|
||||
def test_existing_member_profile_migrates_once_without_changing_provider():
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}
|
||||
cfg = {"providers": {"turkservis": dict(entry)}, "model": {"default": "kept"}}
|
||||
assert configure_existing_media(cfg)
|
||||
assert cfg["providers"]["turkservis"] == entry
|
||||
assert cfg["model"] == {"default": "kept"}
|
||||
assert not configure_existing_media(cfg)
|
||||
def test_backend_never_registers_a_turkservis_media_mcp_server():
|
||||
assert importlib.util.find_spec("hermes_cli.aiturk_media") is None
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
for needle in ("aiturk_media", "turkservis-media", "generate_media", "ai.turkservis.online/mcp"):
|
||||
assert needle not in source
|
||||
|
||||
|
||||
def test_member_key_is_referenced_without_copying_or_overwriting_other_servers():
|
||||
cfg = {"mcp_servers": {"other": {"url": "https://example.org/mcp"}}}
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "HERMES_CUSTOM_TURKSERVIS_API_KEY"}
|
||||
assert configure_media(cfg, entry)
|
||||
assert cfg["mcp_servers"]["other"] == {"url": "https://example.org/mcp"}
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"] == "Bearer ${HERMES_CUSTOM_TURKSERVIS_API_KEY}"
|
||||
assert not configure_media(cfg, entry)
|
||||
entry["key_env"] = "ROTATED_MEMBER_KEY"
|
||||
assert configure_media(cfg, entry)
|
||||
assert "ROTATED_MEMBER_KEY" in cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"]
|
||||
def test_no_backend_module_writes_the_turkservis_mcp_endpoint_into_config():
|
||||
offenders = [
|
||||
path.relative_to(ROOT).as_posix()
|
||||
for package in ("hermes_cli", "agent", "tools", "gateway", "tui_gateway")
|
||||
for path in (ROOT / package).rglob("*.py")
|
||||
if "ai.turkservis.online/mcp" in path.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_other_provider_and_missing_key_do_not_enable_media():
|
||||
for entry in ({"base_url": "https://example.com/v1", "key_env": "SECRET"}, {"base_url": "https://ai.turkservis.online/v1"}):
|
||||
cfg = {}
|
||||
assert not configure_media(cfg, entry)
|
||||
assert cfg == {}
|
||||
def test_existing_user_mcp_entries_are_left_exactly_as_they_are():
|
||||
# Startup used to rewrite config.yaml; with the writer gone, a saved config
|
||||
# round-trips untouched (including a disabled turkservis-media entry).
|
||||
before = {
|
||||
"providers": {"turkservis": {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}},
|
||||
"mcp_servers": {
|
||||
"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False},
|
||||
"other": {"url": "https://example.org/mcp"},
|
||||
},
|
||||
}
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
lifespan = source[source.index("async def _lifespan("):]
|
||||
lifespan = lifespan[:lifespan.index("yield")]
|
||||
assert "save_config" not in lifespan and "mcp_servers" not in lifespan
|
||||
assert yaml.safe_load(yaml.safe_dump(before)) == before
|
||||
|
||||
|
||||
def test_explicitly_disabled_connection_stays_disabled():
|
||||
cfg = {"mcp_servers": {"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False}}}
|
||||
configure_media(cfg, {"base_url": "https://ai.turkservis.online/v1", "key_env": "KEY"})
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["enabled"] is False
|
||||
def test_bundled_skill_teaches_aiturk_media():
|
||||
text = SKILL.read_text(encoding="utf-8")
|
||||
meta = yaml.safe_load(text.split("---")[1])
|
||||
assert meta["name"] == "aiturk-media"
|
||||
for phrase in ("aiturk media list --json", "aiturk media run image", "aiturk media run video",
|
||||
"aiturk media run tts", "aiturk media run stt", "aiturk login"):
|
||||
assert phrase in text
|
||||
assert "Do not add or enable an MCP media server" in text
|
||||
Reference in new issue
Block a user