Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 | ||
|
|
9240576937 | ||
|
|
233885d3a7 | ||
|
|
9a1d831d4b | ||
|
|
1fe367177c | ||
|
|
40c3c74282 | ||
|
|
ee73fa8cce | ||
|
|
f960716483 | ||
|
|
5f75dcc99a | ||
|
|
ac6687181e | ||
|
|
3f12c627ab | ||
|
|
4115f1b4c0 | ||
|
|
c1929957a2 | ||
|
|
b66daed07a | ||
|
|
9f9f3226a1 |
No files matched your search
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)",
|
||||
"version": "14.44.35211.0",
|
||||
"architecture": "x64",
|
||||
"publisher": "Microsoft Corporation (Authenticode doğrulandı)",
|
||||
"files": [
|
||||
{
|
||||
"name": "msvcp140.dll",
|
||||
"size": 557728,
|
||||
"sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_1.dll",
|
||||
"size": 35952,
|
||||
"sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_2.dll",
|
||||
"size": 280200,
|
||||
"sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_atomic_wait.dll",
|
||||
"size": 50304,
|
||||
"sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_codecvt_ids.dll",
|
||||
"size": 31872,
|
||||
"sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18"
|
||||
},
|
||||
{
|
||||
"name": "concrt140.dll",
|
||||
"size": 324208,
|
||||
"sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376"
|
||||
},
|
||||
{
|
||||
"name": "vcruntime140.dll",
|
||||
"size": 124544,
|
||||
"sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066",
|
||||
"ownedByPython": true
|
||||
},
|
||||
{
|
||||
"name": "vcruntime140_1.dll",
|
||||
"size": 49792,
|
||||
"sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f",
|
||||
"ownedByPython": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import https from 'node:https'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import tls from 'node:tls'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
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,
|
||||
@@ -32,6 +34,16 @@ import {
|
||||
} from 'electron'
|
||||
|
||||
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'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||
@@ -5220,6 +5232,22 @@ async function ensureRuntime(backend) {
|
||||
)
|
||||
}
|
||||
|
||||
// Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the
|
||||
// managed CPython does not ship. Place the bundled Microsoft CRT beside the
|
||||
// managed python.exe before the backend imports anything. Never blocks boot:
|
||||
// only the voice features depend on it.
|
||||
if (IS_WINDOWS) {
|
||||
const vcRuntime = ensureWindowsVcRuntime({
|
||||
platform: process.platform,
|
||||
venvRoot: VENV_ROOT,
|
||||
sourceDir: IS_PACKAGED
|
||||
? path.join(process.resourcesPath, 'vc-runtime', 'x64')
|
||||
: path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64')
|
||||
})
|
||||
|
||||
rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`)
|
||||
}
|
||||
|
||||
backend.command = getVenvPython(VENV_ROOT)
|
||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||
updateBootProgress({
|
||||
@@ -17213,6 +17241,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`).
|
||||
@@ -17717,6 +17792,10 @@ if (!isPrimaryInstance) {
|
||||
app.exit(0)
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (process.platform === 'win32' && argv.includes('--aiturk-mobile')) {
|
||||
openMobileConnectionWindow()
|
||||
return
|
||||
}
|
||||
const url = _extractDeepLink(argv)
|
||||
|
||||
if (url) {
|
||||
@@ -17740,7 +17819,98 @@ app.on('open-url', (event, url) => {
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
let mobileConnectionWindow: BrowserWindow | null = null
|
||||
function openMobileConnectionWindow() {
|
||||
if (mobileConnectionWindow && !mobileConnectionWindow.isDestroyed()) {
|
||||
mobileConnectionWindow.show()
|
||||
mobileConnectionWindow.focus()
|
||||
return
|
||||
}
|
||||
mobileConnectionWindow = createMobileWindow({
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
link: getMobileLink()
|
||||
})
|
||||
}
|
||||
|
||||
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
|
||||
// the loopback backend + pairing. One controller for the whole app run; it only
|
||||
// listens while the feature is on and a phone is paired or being paired.
|
||||
let mobileLink: MobileLink | null = null
|
||||
|
||||
function mobileLinkRemoteMode() {
|
||||
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
|
||||
}
|
||||
|
||||
function getMobileLink(): MobileLink {
|
||||
if (mobileLink) {
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||
vault: new MobileVault(directory, safeStorage),
|
||||
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||
resolveBackend: async () => {
|
||||
if (mobileLinkRemoteMode()) {
|
||||
throw new Error('aiturk-mobile:remote_mode')
|
||||
}
|
||||
|
||||
const connection = await ensureBackend(primaryProfileKey())
|
||||
|
||||
if (connection.mode !== 'local') {
|
||||
throw new Error('aiturk-mobile:remote_mode')
|
||||
}
|
||||
|
||||
return { baseUrl: connection.baseUrl, token: connection.token }
|
||||
},
|
||||
isRemoteMode: mobileLinkRemoteMode,
|
||||
firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis.
|
||||
log: line => {
|
||||
if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may open the phone window.
|
||||
ipcMain.handle('aiturk:mobile:open', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('aiturk-mobile:unsupported')
|
||||
}
|
||||
|
||||
openMobileConnectionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
void mobileLink?.stop().catch(() => undefined)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Explicit development/beta entry point; never enables remote access on boot.
|
||||
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
||||
// Resume the phone link only when the user turned it on earlier AND a phone
|
||||
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
|
||||
if (process.platform === 'win32') {
|
||||
void getMobileLink()
|
||||
.start()
|
||||
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
|
||||
}
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
void startChromeBridge({
|
||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ChromeGatewayClient } from './chrome-bridge.mjs'
|
||||
|
||||
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
|
||||
|
||||
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
|
||||
// Journal stores only hashes and delivery state, no prompt, token, or result.
|
||||
export class DeliveryJournal {
|
||||
constructor(directory) { this.directory = directory; this.writes = Promise.resolve() }
|
||||
async reserve(deviceId, taskId, envelope) {
|
||||
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
|
||||
const hash = digest(envelope)
|
||||
try {
|
||||
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
|
||||
return { file, hash, fresh: true }
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error
|
||||
let previous
|
||||
try { previous = JSON.parse(await readFile(file, 'utf8')) }
|
||||
catch { return { file, hash, fresh: false, state: 'unknown' } }
|
||||
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
|
||||
return { file, hash, fresh: false, state: previous.state }
|
||||
}
|
||||
}
|
||||
async accepted(reservation) {
|
||||
return this.update(reservation, 'accepted')
|
||||
}
|
||||
update(reservation, state) {
|
||||
const operation = this.writes.then(async () => {
|
||||
const previous = JSON.parse(await readFile(reservation.file, 'utf8'))
|
||||
if (['complete', 'error', 'interrupted'].includes(previous.state)) return
|
||||
const temporary = `${reservation.file}.tmp`
|
||||
await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true })
|
||||
await rename(temporary, reservation.file)
|
||||
})
|
||||
this.writes = operation.catch(() => {})
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
export class MobileGatewayClient extends ChromeGatewayClient {
|
||||
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
|
||||
super(resolveBackend, emit, webSocket)
|
||||
this.deviceId = deviceId
|
||||
this.journal = journal
|
||||
this.authorize = authorize
|
||||
this.approval = null
|
||||
this.model = null
|
||||
this.chain = Promise.resolve()
|
||||
this.tasks = new Map()
|
||||
}
|
||||
|
||||
onEvent(event) {
|
||||
if (['task.start', 'task.complete'].includes(event?.type)) {
|
||||
const task = this.tasks.get(event.payload?.task_id)
|
||||
if (!task || task.runtimeId !== event.session_id || task.terminal) return
|
||||
const state = event.type === 'task.start' ? 'running' : event.payload.status
|
||||
if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return
|
||||
task.terminal = state !== 'running'
|
||||
task.state = state
|
||||
if (task.terminal) this.journal.update(task.reservation, state).catch(() => {})
|
||||
this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state,
|
||||
text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' })
|
||||
return
|
||||
}
|
||||
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
|
||||
if (event.type === 'approval.request') {
|
||||
this.approval = this.approvalView(event.payload)
|
||||
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
|
||||
return
|
||||
}
|
||||
if (event.type === 'message.complete') this.approval = null
|
||||
super.onEvent(event)
|
||||
}
|
||||
|
||||
approvalView(payload) {
|
||||
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
|
||||
// Retain full backend-redacted operation; never approve a clipped preview.
|
||||
if (payload.command.length > 48000) return null
|
||||
return { requestId: payload.request_id, command: payload.command,
|
||||
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
||||
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
|
||||
}
|
||||
|
||||
handle(message) {
|
||||
const result = this.chain.then(() => this.perform(message))
|
||||
this.chain = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
async perform(message) {
|
||||
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
|
||||
await this.authorize()
|
||||
const params = message.params || {}
|
||||
if (message.action === 'session.open') {
|
||||
const list = await super.handle({ id: message.id, action: 'sessions.list' })
|
||||
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
|
||||
const result = await super.handle(message)
|
||||
this.model = result.model
|
||||
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||
this.approval = this.approvalView(resumed.pending_approval)
|
||||
return { ...result, approval: this.approval }
|
||||
}
|
||||
if (message.action === 'task.submit') {
|
||||
const capabilities = await this.rpc('gateway.capabilities', {})
|
||||
if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.')
|
||||
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
|
||||
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
|
||||
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
|
||||
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
|
||||
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
|
||||
// A model change by the desktop between selection and delivery fails closed.
|
||||
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
|
||||
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
|
||||
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
|
||||
model: params.model, text: params.text, intent: params.intent }
|
||||
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
|
||||
const correlation = digest([this.deviceId, params.taskId])
|
||||
if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId,
|
||||
runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' })
|
||||
if (!reservation.fresh) {
|
||||
// Recover a terminal event missed while the phone/companion was closed.
|
||||
// Never resubmit even if the bounded replay window no longer has it.
|
||||
const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 })
|
||||
for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params)
|
||||
await this.journal.writes
|
||||
const state = this.tasks.get(correlation).state
|
||||
return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) }
|
||||
}
|
||||
await this.authorize()
|
||||
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true,
|
||||
client_task_id: correlation })
|
||||
await this.journal.accepted(reservation)
|
||||
if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted'
|
||||
return { taskId: params.taskId, state: 'accepted' }
|
||||
}
|
||||
if (message.action === 'approval.respond') {
|
||||
if (this.selected?.storedId !== params.sessionId || !this.approval ||
|
||||
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
|
||||
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
|
||||
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
|
||||
request_id: params.requestId, choice: params.choice, all: false })
|
||||
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
|
||||
this.approval = null
|
||||
return { resolved: true }
|
||||
}
|
||||
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
|
||||
throw new Error('Bu mobil işlem desteklenmiyor.')
|
||||
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
|
||||
throw new Error('Önce konuşmayı seçin.')
|
||||
return super.handle(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
|
||||
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
|
||||
/* global window, document */
|
||||
import { drawQr } from './mobile/qr.mjs'
|
||||
|
||||
const TEXT = {
|
||||
tr: {
|
||||
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||
intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.',
|
||||
enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı',
|
||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||
firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
|
||||
sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||
footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
errors: {
|
||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.',
|
||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||
intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.',
|
||||
enabled: 'Phone connection is on', disabled: 'Phone connection is off',
|
||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||
firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.',
|
||||
firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.',
|
||||
firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.',
|
||||
retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
|
||||
sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||
footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
errors: {
|
||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||
not_enabled: 'Turn phone connection on first.',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).',
|
||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||
failed: 'That did not work. Try again.',
|
||||
},
|
||||
},
|
||||
}
|
||||
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
|
||||
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
|
||||
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||
['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||
const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed
|
||||
|
||||
function tick() {
|
||||
const pairing = current?.pairing
|
||||
if (!pairing) return
|
||||
const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))
|
||||
$('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0'))
|
||||
if (left === 0) { notice(t.expired); void refresh() }
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
current = state
|
||||
$('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled
|
||||
$('remote-box').hidden = !state.remote
|
||||
const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall]
|
||||
$('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || ''
|
||||
$('pair-section').hidden = !state.enabled || state.remote
|
||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||
if (state.pairing) {
|
||||
if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr }
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||
tick()
|
||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||
$('devices-empty').hidden = state.devices.length > 0
|
||||
$('devices').replaceChildren(...state.devices.map(device => {
|
||||
const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong')
|
||||
const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button')
|
||||
row.className = 'device'; name.textContent = device.name
|
||||
added.textContent = `${t.added}: ${when(device.createdAt)}`
|
||||
seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}`
|
||||
button.textContent = t.remove; button.className = 'danger'
|
||||
button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) })
|
||||
label.append(name, added, seen); row.append(label, button)
|
||||
return row
|
||||
}))
|
||||
$('footer').hidden = state.port === null
|
||||
$('footer-info').textContent = state.port === null ? '' : t.footer(state.port)
|
||||
$('firewall-remove').hidden = state.firewall !== 'ok'
|
||||
}
|
||||
|
||||
async function run(work) {
|
||||
if (busy) return
|
||||
busy = true; document.body.setAttribute('aria-busy', 'true')
|
||||
for (const button of document.querySelectorAll('button,input')) button.disabled = true
|
||||
try { await work() } catch (error) { notice(errorText(error)); await refresh() }
|
||||
finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false }
|
||||
}
|
||||
async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(error)) } }
|
||||
|
||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) })
|
||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||
setInterval(tick, 1000)
|
||||
// "Last seen" and the live marker change without an event; keep the list fresh while the window is open.
|
||||
setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000)
|
||||
void refresh()
|
||||
@@ -0,0 +1,16 @@
|
||||
/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */
|
||||
.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer }
|
||||
.switch input { width:26px; height:26px; margin:0; flex:none }
|
||||
.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 }
|
||||
.warning p { color:var(--text); margin:4px 0 }
|
||||
.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all }
|
||||
.countdown { text-align:center; color:var(--text); font-weight:600 }
|
||||
.hint { text-align:center }
|
||||
#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px }
|
||||
#pair-live p { text-align:center }
|
||||
.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 }
|
||||
.device strong { color:var(--text); display:block; overflow-wrap:anywhere }
|
||||
.device small { display:block }
|
||||
.device button { flex:none; margin:0 }
|
||||
footer { margin-top:34px }
|
||||
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'">
|
||||
<title>AITURK · Telefon bağlantısı</title><link rel="stylesheet" href="mobile/style.css"><link rel="stylesheet" href="mobile-host.css"></head><body><main>
|
||||
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</h1>
|
||||
<p id="t-intro"></p>
|
||||
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||
<section id="firewall-box" class="warning" hidden><p id="firewall-text"></p><button id="firewall-retry" class="secondary"></button></section>
|
||||
<section id="pair-section" hidden>
|
||||
<button id="pair-begin"></button>
|
||||
<div id="pair-live" hidden>
|
||||
<p id="t-scan"></p>
|
||||
<canvas id="pair-qr" role="img"></canvas>
|
||||
<p id="t-or-code"></p>
|
||||
<p id="pair-code" class="code" aria-live="off"></p>
|
||||
<p id="pair-code-path"></p>
|
||||
<p id="pair-countdown" class="countdown"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
<button id="pair-cancel" class="secondary"></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="devices-section" hidden>
|
||||
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||
</section>
|
||||
<footer id="footer" hidden><small id="footer-info"></small> <button id="firewall-remove" class="link"></button></footer>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
@@ -0,0 +1,45 @@
|
||||
// "Telefon bağlantısı" window: a thin IPC shell around the controller built in
|
||||
// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the
|
||||
// main process for the whole app run — closing this window only cancels a
|
||||
// pairing in progress; paired phones stay connected.
|
||||
import { join } from 'node:path'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
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 } })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
window.webContents.on('will-navigate', event => event.preventDefault())
|
||||
const actions = {
|
||||
state: () => link.state(),
|
||||
enable: () => link.enable(),
|
||||
disable: () => link.disable(),
|
||||
'pair.begin': () => link.pairBegin(),
|
||||
'pair.cancel': () => link.pairCancel(),
|
||||
devices: () => link.devices(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
'firewall.retry': () => link.firewallRetry(),
|
||||
'firewall.remove': () => link.firewallRemove(),
|
||||
}
|
||||
const handler = async (event, action, params) => {
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden')
|
||||
if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported')
|
||||
try { return await actions[action](params) } catch (error) {
|
||||
// Only fixed codes cross to the renderer; never upstream error text.
|
||||
throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed')
|
||||
}
|
||||
}
|
||||
const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) })
|
||||
ipcMain.handle('aiturk-mobile', handler)
|
||||
window.on('closed', () => {
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
contextBridge.exposeInMainWorld('mobileHost', {
|
||||
call: (action, params) => ipcRenderer.invoke('aiturk-mobile', action, params),
|
||||
onEvent: callback => ipcRenderer.on('aiturk-mobile-event', (_event, value) => callback(value)),
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ASSISTANT_INSTRUCTION, VOICE_TOOLS } from '../../mobile/src/behavior.mjs'
|
||||
|
||||
export class MobileVault {
|
||||
constructor(directory, safeStorage) { this.directory = directory; this.safeStorage = safeStorage }
|
||||
async load() {
|
||||
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
|
||||
try {
|
||||
const data = await readFile(join(this.directory, 'mobile-secrets.bin'))
|
||||
return JSON.parse(this.safeStorage.decryptString(data))
|
||||
} catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Mobil anahtar kasası açılamadı.') }
|
||||
}
|
||||
async save(value) {
|
||||
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
|
||||
const encrypted = this.safeStorage.encryptString(JSON.stringify(value))
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const file = join(this.directory, 'mobile-secrets.bin')
|
||||
await writeFile(file + '.tmp', encrypted, { mode: 0o600 })
|
||||
await rename(file + '.tmp', file)
|
||||
}
|
||||
}
|
||||
|
||||
export const LIVE_MODEL = 'gemini-3.1-flash-live-preview'
|
||||
export class LiveTokenIssuer {
|
||||
constructor(vault, fetcher = fetch, now = Date.now) { this.vault = vault; this.fetcher = fetcher; this.now = now; this.last = new Map() }
|
||||
async issue(deviceId) {
|
||||
const now = this.now()
|
||||
if (now - (this.last.get(deviceId) ?? -Infinity) < 30000) throw new Error('Yeni ses bağlantısı için 30 saniye bekleyin.')
|
||||
this.last.set(deviceId, now)
|
||||
const secrets = await this.vault.load()
|
||||
if (!secrets.googleKey) throw new Error('Önce kendi Google AI Studio anahtarınızı ekleyin.')
|
||||
let response
|
||||
try {
|
||||
response = await this.fetcher('https://generativelanguage.googleapis.com/v1beta/auth_tokens', {
|
||||
method: 'POST', headers: { 'x-goog-api-key': secrets.googleKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uses: 1, expireTime: new Date(now + 10 * 60000).toISOString(),
|
||||
newSessionExpireTime: new Date(now + 60000).toISOString(),
|
||||
bidiGenerateContentSetup: { model: `models/${LIVE_MODEL}`,
|
||||
generationConfig: { responseModalities: ['AUDIO'] },
|
||||
systemInstruction: { parts: [{ text: ASSISTANT_INSTRUCTION }] },
|
||||
tools: [{ functionDeclarations: VOICE_TOOLS }],
|
||||
inputAudioTranscription: {}, outputAudioTranscription: {} } }),
|
||||
redirect: 'error', signal: AbortSignal.timeout(20000) })
|
||||
} catch { throw new Error('Google ses erişimine ulaşılamadı.') }
|
||||
if (!response.ok) throw new Error(response.status === 429 ? 'Google kotası veya istek sınırı doldu. AI Studio üzerinden kontrol edin.'
|
||||
: response.status === 401 || response.status === 403 ? 'Google anahtarı veya ses modeli erişimi geçersiz.' : 'Google ses erişimi oluşturulamadı.')
|
||||
const data = await response.json()
|
||||
if (typeof data.name !== 'string' || !data.name.startsWith('auth_tokens/')) throw new Error('Google geçerli kısa ömürlü erişim döndürmedi.')
|
||||
return { token: data.name, model: LIVE_MODEL, expiresAt: new Date(now + 10 * 60000).toISOString() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { afterEach, test } from 'vitest'
|
||||
|
||||
import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
function tempRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-'))
|
||||
roots.push(root)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function sha256(data: Buffer) {
|
||||
return crypto.createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir
|
||||
// whose manifest describes the given files.
|
||||
function fixture(files: Record<string, { data: string; ownedByPython?: boolean }>) {
|
||||
const root = tempRoot()
|
||||
const home = path.join(root, 'python home')
|
||||
const venv = path.join(root, 'venv')
|
||||
const source = path.join(root, 'bundle')
|
||||
|
||||
for (const dir of [home, venv, source]) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(home, 'python.exe'), 'exe')
|
||||
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`)
|
||||
|
||||
const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => {
|
||||
const bytes = Buffer.from(data)
|
||||
fs.writeFileSync(path.join(source, name), bytes)
|
||||
|
||||
return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) }
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'manifest.json'),
|
||||
JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries })
|
||||
)
|
||||
|
||||
return { home, venv, source }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('non-Windows platforms are left untouched', () => {
|
||||
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||
|
||||
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), {
|
||||
status: 'skipped',
|
||||
reason: 'not windows'
|
||||
})
|
||||
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||
})
|
||||
|
||||
test('readPythonHome parses pyvenv.cfg home with spaces', () => {
|
||||
const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||
|
||||
assert.equal(readPythonHome(venv), home)
|
||||
assert.equal(readPythonHome(path.join(venv, 'missing')), null)
|
||||
})
|
||||
|
||||
test('missing CRT files are copied beside python.exe and verified', () => {
|
||||
const { home, venv, source } = fixture({
|
||||
'msvcp140.dll': { data: 'msvcp' },
|
||||
'msvcp140_1.dll': { data: 'msvcp1' }
|
||||
})
|
||||
|
||||
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||
|
||||
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] })
|
||||
assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1')
|
||||
assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), [])
|
||||
// Second launch: nothing to do.
|
||||
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), {
|
||||
status: 'ok',
|
||||
pythonHome: home,
|
||||
copied: []
|
||||
})
|
||||
})
|
||||
|
||||
test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => {
|
||||
const { home, venv, source } = fixture({
|
||||
'msvcp140.dll': { data: 'good' },
|
||||
'vcruntime140.dll': { data: 'bundled', ownedByPython: true }
|
||||
})
|
||||
|
||||
fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale')
|
||||
fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned')
|
||||
|
||||
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||
|
||||
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] })
|
||||
assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good')
|
||||
assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned')
|
||||
})
|
||||
|
||||
test('a tampered bundled file is refused and nothing is written', () => {
|
||||
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } })
|
||||
|
||||
fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered')
|
||||
|
||||
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||
|
||||
assert.equal(result.status, 'failed')
|
||||
assert.match((result as { error: string }).error, /does not match its manifest digest/)
|
||||
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||
})
|
||||
|
||||
test('an unresolvable python home fails without throwing', () => {
|
||||
const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||
|
||||
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n')
|
||||
|
||||
assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed')
|
||||
})
|
||||
|
||||
test('the shipped CRT set matches its manifest and covers the voice wheels', () => {
|
||||
const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64')
|
||||
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||
const names = manifest.files.map(file => file.name)
|
||||
|
||||
for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) {
|
||||
assert.ok(names.includes(required), `${required} must be bundled`)
|
||||
}
|
||||
|
||||
for (const file of manifest.files) {
|
||||
const bytes = fs.readFileSync(path.join(sourceDir, file.name))
|
||||
|
||||
assert.equal(bytes.length, file.size, file.name)
|
||||
assert.equal(sha256(bytes), file.sha256, file.name)
|
||||
}
|
||||
})
|
||||
|
||||
test('the Windows package ships the CRT set as an extra resource', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
|
||||
const resources = pkg.build.win.extraResources
|
||||
|
||||
assert.ok(
|
||||
resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'),
|
||||
'win.extraResources must carry vc-runtime/x64'
|
||||
)
|
||||
})
|
||||
|
||||
test('ensureRuntime places the CRT before handing the venv python to the backend', () => {
|
||||
const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
|
||||
const start = source.indexOf('async function ensureRuntime(')
|
||||
const body = source.slice(start, source.indexOf('\nfunction ', start + 1))
|
||||
|
||||
const ensureIdx = body.indexOf('ensureWindowsVcRuntime(')
|
||||
const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)')
|
||||
|
||||
assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime')
|
||||
assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen')
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
// ── Windows VC++ runtime for the managed Python ─────────────────────────────
|
||||
//
|
||||
// Voice features load native wheels (onnxruntime for the wake word,
|
||||
// ctranslate2 for local speech-to-text) that link against msvcp140.dll and
|
||||
// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a
|
||||
// Windows install without the system-wide VC++ Redistributable those imports
|
||||
// fail with "DLL load failed / The specified module could not be found".
|
||||
//
|
||||
// The desktop package carries the Microsoft-signed VC143 CRT set and copies it
|
||||
// next to the managed python.exe before every local backend start. That
|
||||
// directory is the process image directory, which Windows searches first for
|
||||
// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can
|
||||
// never win. Existing installs are healed on their next launch.
|
||||
|
||||
export interface VcRuntimeFile {
|
||||
name: string
|
||||
size: number
|
||||
sha256: string
|
||||
// Files the managed CPython already ships. Never overwrite an existing copy:
|
||||
// the interpreter owns it and replacing a loaded DLL is not possible anyway.
|
||||
ownedByPython?: boolean
|
||||
}
|
||||
|
||||
export interface VcRuntimeManifest {
|
||||
schemaVersion: 1
|
||||
version: string
|
||||
architecture: string
|
||||
files: VcRuntimeFile[]
|
||||
}
|
||||
|
||||
export type VcRuntimeResult =
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] }
|
||||
| { status: 'failed'; pythonHome: string | null; copied: string[]; error: string }
|
||||
|
||||
const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i
|
||||
const SHA256_RE = /^[0-9a-f]{64}$/
|
||||
|
||||
export function readPythonHome(venvRoot: string): string | null {
|
||||
let text: string
|
||||
|
||||
try {
|
||||
text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line)
|
||||
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8'))
|
||||
|
||||
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||
throw new Error('invalid VC++ runtime manifest')
|
||||
}
|
||||
|
||||
for (const file of manifest.files) {
|
||||
if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) {
|
||||
throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||
}
|
||||
|
||||
function matches(filePath: string, file: VcRuntimeFile): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) {
|
||||
const source = path.join(sourceDir, file.name)
|
||||
|
||||
if (!matches(source, file)) {
|
||||
throw new Error(`bundled ${file.name} does not match its manifest digest`)
|
||||
}
|
||||
|
||||
const target = path.join(pythonHome, file.name)
|
||||
const temp = `${target}.aiturk-${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.copyFileSync(source, temp)
|
||||
|
||||
if (!matches(temp, file)) {
|
||||
throw new Error(`copied ${file.name} failed digest verification`)
|
||||
}
|
||||
|
||||
fs.renameSync(temp, target)
|
||||
} finally {
|
||||
fs.rmSync(temp, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureWindowsVcRuntime({
|
||||
platform,
|
||||
venvRoot,
|
||||
sourceDir
|
||||
}: {
|
||||
platform: NodeJS.Platform
|
||||
venvRoot: string
|
||||
sourceDir: string
|
||||
}): VcRuntimeResult {
|
||||
if (platform !== 'win32') {
|
||||
return { status: 'skipped', reason: 'not windows' }
|
||||
}
|
||||
|
||||
const pythonHome = readPythonHome(venvRoot)
|
||||
|
||||
if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) {
|
||||
return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' }
|
||||
}
|
||||
|
||||
const copied: string[] = []
|
||||
|
||||
try {
|
||||
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||
|
||||
for (const file of manifest.files) {
|
||||
const target = path.join(pythonHome, file.name)
|
||||
|
||||
if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) {
|
||||
continue
|
||||
}
|
||||
|
||||
installFile(sourceDir, pythonHome, file)
|
||||
copied.push(file.name)
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied }
|
||||
}
|
||||
|
||||
export function describeVcRuntimeResult(result: VcRuntimeResult): string {
|
||||
if (result.status === 'skipped') {
|
||||
return `skipped (${result.reason})`
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` +
|
||||
(result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '')
|
||||
}
|
||||
|
||||
return result.status === 'repaired'
|
||||
? `installed ${result.copied.join(', ')} into ${result.pythonHome}`
|
||||
: `verified in ${result.pythonHome}`
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.10",
|
||||
"version": "1.0.0-beta.15",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
@@ -290,7 +290,17 @@
|
||||
"nsis",
|
||||
"msi"
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
"signAndEditExecutable": false,
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "assets/windows/vc-runtime/x64",
|
||||
"to": "vc-runtime/x64",
|
||||
"filter": [
|
||||
"*.dll",
|
||||
"manifest.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||
// separately via stage-native-deps).
|
||||
import { build } from 'esbuild'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { mkdirSync, cpSync, existsSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
@@ -63,3 +64,25 @@ await build({
|
||||
logLevel: 'info',
|
||||
})
|
||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
||||
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 })
|
||||
// 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
|
||||
// look them up along Node's resolution path from apps/mobile instead of
|
||||
// assuming apps/mobile/node_modules exists.
|
||||
const mobileRequire = createRequire(resolve(root, '../mobile/package.json'))
|
||||
function mobilePackageDir(name) {
|
||||
for (const base of mobileRequire.resolve.paths(name) ?? []) {
|
||||
const dir = resolve(base, name)
|
||||
if (existsSync(resolve(dir, 'package.json'))) return dir
|
||||
}
|
||||
throw new Error(`${name} is not installed for apps/mobile; run npm ci at the repository root`)
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.bat text eol=crlf
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
android/**/build/
|
||||
*.keystore
|
||||
*.jks
|
||||
test-results/
|
||||
@@ -0,0 +1,198 @@
|
||||
# AITURK Mobil — Android geliştirme sürümü 0.1.4
|
||||
|
||||
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
|
||||
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
|
||||
Google Live ve gerçek telefon/ağ doğrulaması henüz yapılmadı. Canlı
|
||||
turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi.
|
||||
|
||||
## Çalışan ve test edilen parçalar
|
||||
|
||||
- Capacitor 8.5.1 Android projesi; Türkçe erişilebilir kurulum ve yazılı kullanım.
|
||||
- Tarayıcıda mevcut AITURK hesabıyla kısa kod onayı; uygulamaya parola girilmez.
|
||||
Dar kapsamlı mobil oturum Windows/Android kasasında tutulur. Mevcut hesap API
|
||||
anahtarı gelişmiş seçenek olarak korunur. Bu yeni giriş henüz canlıya dağıtılmadı.
|
||||
Telefonun eşleşmesini hedef bilgisayar kendi cihaz tokenıyla onaylar.
|
||||
- WebRTC DTLS/SCTP veri kanalı; büyük Unicode mesajları sınırlı parçalara bölme.
|
||||
- Mevcut IDE `/api/sessions` ve JSON-RPC adaptörü. Konuşma ve seçili model
|
||||
gönderimden önce doğrulanır. Model listesi icat edilmez.
|
||||
- Görevden önce diske yazılan tekrar gönderim kaydı. Kabul sonucu belirsizse
|
||||
ikinci kez çalıştırılmaz. Telefonun bekleyen gönderimi Android kasasında kalır.
|
||||
- Gerçek IDE onay kimliği ve redakte edilmiş tam komutla yalnız `once`/`deny`.
|
||||
Sürekli/genel izin, sudo/UAC veya ses modeli üzerinden onay yolu yoktur.
|
||||
- Google anahtarı Windows `safeStorage`, hesap cihaz sırrı Android Keystore
|
||||
AES-GCM kullanır. Android yedekleme ve cihazlar arası veri taşıma kapatılır.
|
||||
- Anahtardan kısa ömürlü, model/araç/sistem talimatı kısıtlı Google Live token
|
||||
üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu.
|
||||
- Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder
|
||||
düğmesi ve ayrı IDE durdur düğmesi vardır.
|
||||
- Google kullanım bildirimleri ücretli liste tarifesiyle tahmin edilir; her
|
||||
isteğin tekrar faturalandırılan geçmişi de sayılır. Ayrıntısı eksik tokenlar
|
||||
için fiyat aralığı gösterilir. Bu fatura, kredi veya ücretsiz kota sorgusu değildir.
|
||||
- Kullanıcı her görüşme için 1–10 dakika ve 0,01–10 USD tahmini durdurma sınırı
|
||||
seçer (varsayılan 10 dakika / 0,25 USD). Üst tahmin sınıra ulaşırsa, süre
|
||||
dolarsa veya 120 saniye kullanım bildirimi gelmezse yalnız Google bağlantısı
|
||||
kapanır. Otomatik yeniden bağlanılmaz. Her yeni oturumda sayaç sıfırlanır;
|
||||
bu günlük/aylık bütçe veya Google tarafında kesin harcama tavanı değildir.
|
||||
|
||||
## Geliştirme kurulumu
|
||||
|
||||
Node 22+, JDK 21 ve Android API 36 gerekir. Bu çalışma sırasında araçlar
|
||||
`%LOCALAPPDATA%/aiturk-mobile-tools` altına kuruldu; kullanıcı sistem PATH'i
|
||||
veya yönetici ayarları değiştirilmedi.
|
||||
|
||||
```powershell
|
||||
# Bu klasörde; kök depodaki kurulumu etkilemeyen Android kurulumu
|
||||
npm ci --workspaces=false --ignore-scripts
|
||||
npm test --workspaces=false
|
||||
npm run android:sync --workspaces=false
|
||||
# JAVA_HOME ve ANDROID_HOME kendi araç yollarınızı göstermeli
|
||||
.\android\gradlew.bat -p android assembleDebug assembleRelease lintDebug
|
||||
```
|
||||
|
||||
`app-debug.apk` geliştirici anahtarıyla imzalanır. `app-release-unsigned.apk`
|
||||
üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim
|
||||
imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı.
|
||||
|
||||
## Uyumlu IDE ve bağlantı servisi
|
||||
|
||||
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
|
||||
Derlenmiş IDE'yi `--aiturk-mobile` parametresiyle açın. IDE zaten açıksa parametre
|
||||
mobil bağlantı penceresini açar; çalışmakta olan konuşma kapatılmaz.
|
||||
Mobil pencere açılmadan uzaktan erişim etkinleşmez.
|
||||
|
||||
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
|
||||
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
|
||||
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
|
||||
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayın. Bilgisayarda
|
||||
“Telefon için QR oluştur” seçip telefonda kamerayla veya QR resmiyle okuyun.
|
||||
4. Bilgisayarda “QR ile gelen telefonu getir” seçin; telefonun gösterdiği kısa
|
||||
kodu girip açıkça onaylayın. Uzun kimliği elle yazmak gerekmez.
|
||||
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
|
||||
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
|
||||
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
|
||||
Veri/ücret bilgisini kabul ettikten sonra Google görüşmesini başlatın.
|
||||
|
||||
Gateway eş değişikliği `portal/routes_mobile.py` ve `router_gateway.py` içindedir.
|
||||
Mevcut member guard korunur. Üretim kurulumu ve TURN koşulları eş gateway
|
||||
deposunun `docs/aiturk-mobile-operations.md` dosyasında açıklanır.
|
||||
|
||||
## Doğrulama ve sınırlar
|
||||
|
||||
0.1.4-dev (Android versionCode 5), 12 Eylül 2026: 41 mobil test geçti;
|
||||
tarayıcı uygulama testi, yerel sertifika/DTLS testi ve Electron IPC/Windows kasa
|
||||
testi geçti. Telefon kimliği QR resminin onaysız içe alınması ve değiştirilmiş
|
||||
cihaz kimliklerinin reddi test edildi. `assembleDebug lintDebug` başarılı,
|
||||
uygulama lint sonucu 0 hata / 17 uyarı. Debug APK imzası doğrulandı ve kimlik,
|
||||
taşıma, uygulama, davet, gizlilik ve ana sayfa dosyaları kaynakla karşılaştırıldı.
|
||||
APK SHA-256: `94835E223B71BE36AFB26686D3EBC79D8976514FFDB4E7CDFC189ACE7E2F12A5`.
|
||||
Bu APK geliştirici imzalıdır; üretim/gerçek Android kabulü değildir.
|
||||
|
||||
12 Eylül 2026 yerel doğrulaması: `npm test` 37/37 geçti. Ses başlangıcı
|
||||
beklerken iptal ve kapatılmış oturumu yeniden açma regresyonları önce başarısız
|
||||
testlerle gösterildi, sonra düzeltildi. `npm run android:sync` ve
|
||||
`gradlew.bat assembleDebug lintDebug` başarılı; uygulama lint sonucu 0 hata,
|
||||
17 uyarı. `scripts/browser-smoke.mjs` gerçek DTLS veri kanalında 240.000
|
||||
karakter aktarımını ve sentetik Google kullanım sınırını doğruladı; Google'a
|
||||
gerçek çağrı yapmadı. APK içindeki `voice.mjs` kaynakla birebir karşılaştırıldı.
|
||||
Debug imzası `apksigner verify` ile doğrulandı; bu üretim imzası değildir.
|
||||
APK SHA-256: `F5DB098DF0F1C59B92F274969FCAD926BCB2DD25AF823533A983085A18BFB966`.
|
||||
Bu kontrolde `adb devices -l` bağlı cihaz göstermedi; fiziksel telefon kabulü
|
||||
ve canlı yayın hâlâ tamamlanmadı.
|
||||
|
||||
`npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları.
|
||||
`scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı,
|
||||
240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi.
|
||||
`scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu;
|
||||
yalnız sentetik bir test anahtarı kullanır.
|
||||
|
||||
`src/peer-identity.mjs` cihaz kimliğine göre kalıcı standart RTCCertificate
|
||||
kaydını, atomik ilk oluşturmayı, kayıtlı parmak iziyle yüklemeyi ve SDP kimlik
|
||||
kontrolünü uygular. Anahtar eksik veya geçersizse yükleme yeni sertifika üretmez.
|
||||
Bilgisayar QR daveti sürüm 2 ile sertifika parmak izini taşır; telefon bunu
|
||||
eşleştirme kaydına alır ve SDP uygulanmadan önce kontrol eder. Bilgisayar
|
||||
bağlantıda kayıtlı sertifikasını kullanır. Eski sürüm 1 QR ve kimliği olmayan
|
||||
elle eşleştirmeler veri bağlantısı açamaz; yeni QR ile yeniden eşleştirme gerekir.
|
||||
Telefon da eşleştirmede kendi sertifikasını oluşturur ve bağlantıda aynı
|
||||
sertifikayı yükler. Kullanıcı telefondaki kimlik belgesini bilgisayardaki
|
||||
ilgili alana aktarır; `phone.approve` belgeyi eşleştirme kimliği ve sunucunun
|
||||
onayladığı cihaz kimliğiyle karşılaştırıp Windows kasasına kaydeder. IDE hem
|
||||
gelen bağlantı listesinde hem `channel.answer` yolunda kayıtlı telefon
|
||||
parmak izini kontrol eder; renderer SDP'yi uygulamadan önce tekrar doğrular.
|
||||
İlk kayıt için yerel açık onay gerekir; mevcut telefon kimliği sessizce
|
||||
değiştirilemez. Telefon kimliğini QR olarak da gösterir. Kullanıcı ekran
|
||||
görüntüsünü kendi bilgisayarına aktararak “Telefon kimliği QR resmi” alanından
|
||||
okutabilir; eşleştirme kimliği ve belge doldurulur, fakat otomatik onay verilmez.
|
||||
Kısa kod ve ayrı onay düğmesi gereklidir; elle belge aktarma yolu da korunur.
|
||||
|
||||
`node scripts/certificate-smoke.mjs`: 12 Eylül 2026'da izole Edge bağlamında
|
||||
yerel WebRTC sertifikasının IndexedDB'ye yazılıp sayfa yenilendikten sonra
|
||||
aynı parmak iziyle geri alınması, geri alınan sertifikayla gerçek DTLS veri
|
||||
aktarımı ve yanlış SDP parmak izinde DTLS'nin `failed` durumuna geçmesi
|
||||
doğrulandı. Test artık gerçek `peer-identity.mjs` modülünü kullanıyor; eşzamanlı
|
||||
dört oluşturma çağrısının tek kimlikte birleşmesi, kayıp kaydın ve yanlış
|
||||
parmak izinin reddi de geçti. Tarayıcı uygulama testi ayrıca QR ile eşlenen
|
||||
bilgisayar yerine farklı sertifikalı SDP geldiğinde görünür kimlik hatası
|
||||
verildiğini ve hiçbir IDE isteği gönderilmediğini doğruladı. Electron IPC testi
|
||||
Windows kasasındaki parmak iziyle sertifikanın pencere yenilemesinden sonra
|
||||
yüklenmesini ve sessiz kimlik değişiminin reddini doğruladı. Bu testlerde portal
|
||||
ve Google gerçek servisleri kullanılmadı; canlı karşılıklı kimlik kabulü değildir.
|
||||
|
||||
Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır:
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/tui_gateway/test_mobile_runtime_integration.py -q
|
||||
```
|
||||
|
||||
Bu test gerçek IDE HTTP, WebSocket, konuşma veritabanı, prompt.submit ve
|
||||
message.complete yolunu çalıştırır. Ücretli çıkarım servisi yerine yerel
|
||||
deterministik sağlayıcı kullanılır. Aynı görev ikinci defa gönderildiğinde IDE
|
||||
geçmişinde yalnız bir kullanıcı görevi oluşur. Gerçek Google/iş modeli kalite
|
||||
ve maliyet testi olarak sayılmaz.
|
||||
|
||||
12 Eylül 2026, 0.1.4 kaynak doğrulaması: zorunlu `scripts/run_tests.sh`
|
||||
sarmalayıcısıyla `test_compute_host.py`, `test_compute_host_phase1.py` ve
|
||||
`test_mobile_runtime_integration.py` birlikte çalıştırıldı. 3 dosyada 16 test
|
||||
geçti, 0 başarısız; süre 32,9 saniye. Windows venv yönlendirici sürecinin
|
||||
bildirdiği PID, gerçek süreç soy ağacı üzerinden doğrulanır. Bu koşu yalnız
|
||||
yerel sentetik sağlayıcı kullanır, telefon donanımı veya Google kabulü değildir.
|
||||
|
||||
Henüz tamamlanmayanlar:
|
||||
|
||||
- Karşılıklı cihaz kimliğinin gerçek Android ve paketlenmiş IDE arasında,
|
||||
yeniden başlatma/iptal/anahtar kaybı senaryolarıyla uçtan uca kabulü.
|
||||
İki yönün kodu var; tarayıcı ve Electron testleri gerçek cihaz kabulünün
|
||||
yerine geçmez. Telefon QR çıktısının pikselden geri okunması ve Electron'da
|
||||
QR resminin açık onay vermeden alanlara aktarılması test edildi; gerçek
|
||||
telefon ekran görüntüsü aktarımı henüz denenmedi.
|
||||
- Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi.
|
||||
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
|
||||
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
|
||||
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
|
||||
- Giriş ve QR artık kodlandı: 9 HTTP/DB testi, 27 mobil testi, gerçek QR pikseli
|
||||
okuyan (hesap/claim yanıtları sahte) tarayıcı akışı geçti. Fiziksel Android
|
||||
kamera/giriş kabulü ve farklı ağ bağlantısı henüz yapılmadı.
|
||||
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
|
||||
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
|
||||
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
|
||||
- Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev
|
||||
sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç
|
||||
bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz.
|
||||
- Maliyet tahmini/ayarlanabilir oturum sınırı uygulandı: 35 Node testi ve
|
||||
derlenmiş uygulamanın gerçek WebRTC, sentetik IDE/Google yanıtlı sınır akışı
|
||||
geçti. Gerçek Google faturasına karşı karşılaştırma henüz yapılmadı.
|
||||
- Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış
|
||||
kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin
|
||||
sinyalleşme hizmetine güven sınırı gizlenmez.
|
||||
- Üretim imzası, kapalı beta kabulü ve turkservis.online indirme yayını.
|
||||
|
||||
Capacitor CLI'nin kullanılmayan iOS araç zincirinde `xcode → uuid` üzerinden
|
||||
3 moderate geliştirme bağımlılığı uyarısı mevcut; Android runtime audit sonucu
|
||||
ayrıca kontrol edilir. Bunları gizlemek için `npm audit fix --force` uygulanmadı.
|
||||
|
||||
## Resmî dayanaklar
|
||||
|
||||
- https://capacitorjs.com/docs/getting-started/environment-setup
|
||||
- https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens
|
||||
- https://ai.google.dev/api/live
|
||||
- https://ai.google.dev/gemini-api/docs/pricing
|
||||
- https://github.com/coturn/coturn/blob/master/README.turnserver
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "online.turkservis.aiturk.mobile"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "online.turkservis.aiturk.mobile"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 5
|
||||
versionName "0.1.4-dev"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:icon="@drawable/aiturk_icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/aiturk_icon"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,11 @@
|
||||
package online.turkservis.aiturk.mobile;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import android.os.Bundle;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(MobileSecretsPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package online.turkservis.aiturk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import java.net.URL;
|
||||
import java.io.InputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Standard Android Keystore AEAD storage. Key never leaves AndroidKeyStore. */
|
||||
@CapacitorPlugin(name = "MobileSecrets")
|
||||
public class MobileSecretsPlugin extends Plugin {
|
||||
private static final String ALIAS = "aiturk.mobile.account.v1";
|
||||
private final AtomicInteger requests = new AtomicInteger();
|
||||
@PluginMethod public void openLogin(PluginCall call) {
|
||||
String url = call.getString("url", "");
|
||||
if (!url.matches("https://turkservis\\.online/account/aiturk/mobile-login/[0-9a-f-]{36}")) {
|
||||
call.reject("Geçersiz giriş adresi."); return;
|
||||
}
|
||||
try {
|
||||
getActivity().startActivity(new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)));
|
||||
call.resolve();
|
||||
} catch (Exception error) { call.reject("Tarayıcı açılamadı."); }
|
||||
}
|
||||
@PluginMethod public void request(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
String method = call.getString("method", "GET");
|
||||
String body = call.getString("body", "");
|
||||
if (!path.matches("/v1/aiturk/[A-Za-z0-9/_.-]+") || path.contains("..") ||
|
||||
!method.matches("GET|POST|DELETE") || body.length() > 65536) {
|
||||
call.reject("Geçersiz hesap isteği."); return;
|
||||
}
|
||||
if (requests.incrementAndGet() > 8) { requests.decrementAndGet(); call.reject("Bekleyen hesap istekleri var."); return; }
|
||||
new Thread(() -> {
|
||||
HttpsURLConnection connection = null;
|
||||
try {
|
||||
connection = (HttpsURLConnection) new URL("https://turkservis.online" + path).openConnection();
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(20000); connection.setReadTimeout(20000);
|
||||
connection.setRequestMethod(method);
|
||||
JSObject headers = call.getObject("headers", new JSObject());
|
||||
for (String name : new String[] {"Authorization", "X-Aiturk-Device-ID", "X-Aiturk-Device-Token"}) {
|
||||
String value = headers.getString(name, "");
|
||||
if (value.length() > 1024 || value.contains("\r") || value.contains("\n")) throw new IllegalArgumentException();
|
||||
if (!value.isEmpty()) connection.setRequestProperty(name, value);
|
||||
}
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
if (!body.isEmpty()) {
|
||||
connection.setDoOutput(true);
|
||||
try (java.io.OutputStream out = connection.getOutputStream()) { out.write(body.getBytes(StandardCharsets.UTF_8)); }
|
||||
}
|
||||
int status = connection.getResponseCode();
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
InputStream input = status < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
if (input != null) try (InputStream stream = input) {
|
||||
byte[] buffer = new byte[4096]; int count;
|
||||
while ((count = stream.read(buffer)) != -1) {
|
||||
if (bytes.size() + count > 262144) throw new IllegalStateException();
|
||||
bytes.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
JSObject result = new JSObject(); result.put("status", status);
|
||||
result.put("body", bytes.toString(StandardCharsets.UTF_8.name())); call.resolve(result);
|
||||
} catch (Exception error) { call.reject("Hesap bağlantısı kurulamadı."); }
|
||||
finally { if (connection != null) connection.disconnect(); requests.decrementAndGet(); }
|
||||
}, "aiturk-account").start();
|
||||
}
|
||||
private SecretKey key() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore"); store.load(null);
|
||||
if (!store.containsAlias(ALIAS)) {
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
generator.init(new KeyGenParameterSpec.Builder(ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build());
|
||||
generator.generateKey();
|
||||
}
|
||||
return (SecretKey) store.getKey(ALIAS, null);
|
||||
}
|
||||
@PluginMethod public void save(PluginCall call) {
|
||||
String value = call.getString("value");
|
||||
if (value == null || value.length() > 65536) { call.reject("Geçersiz hesap kaydı."); return; }
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||
String encrypted = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP) + ":" +
|
||||
Base64.encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP);
|
||||
boolean saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE)
|
||||
.edit().putString("account", encrypted).commit();
|
||||
if (!saved) throw new IllegalStateException();
|
||||
call.resolve();
|
||||
} catch (Exception error) { call.reject("Android güvenli kasasına kaydedilemedi."); }
|
||||
}
|
||||
@PluginMethod public void load(PluginCall call) {
|
||||
try {
|
||||
String saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE).getString("account", null);
|
||||
JSObject result = new JSObject();
|
||||
if (saved != null) {
|
||||
String[] parts = saved.split(":", 2);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)));
|
||||
result.put("value", new String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), StandardCharsets.UTF_8));
|
||||
}
|
||||
call.resolve(result);
|
||||
} catch (Exception error) { call.reject("Android güvenli kasası açılamadı."); }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="256" android:viewportHeight="256">
|
||||
<path android:fillColor="#081525" android:pathData="M0,0H256V256H0Z"/>
|
||||
<path android:fillColor="#24D4C6" android:pathData="M128,30L230,218H185L164,178H92L71,218H26L128,30ZM128,109L106,151H150L128,109Z"/>
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M128,58L148,96H108L128,58Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
<string name="title_activity_main">AITURK Mobil</string>
|
||||
<string name="package_name">online.turkservis.aiturk.mobile</string>
|
||||
<string name="custom_url_scheme">online.turkservis.aiturk.mobile</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#081525</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/aiturk_icon</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<exclude domain="root" path="."/>
|
||||
<exclude domain="file" path="."/>
|
||||
<exclude domain="database" path="."/>
|
||||
<exclude domain="sharedpref" path="."/>
|
||||
<exclude domain="external" path="."/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="."/>
|
||||
<exclude domain="file" path="."/>
|
||||
<exclude domain="database" path="."/>
|
||||
<exclude domain="sharedpref" path="."/>
|
||||
<exclude domain="external" path="."/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appId": "online.turkservis.aiturk.mobile",
|
||||
"appName": "AITURK Mobil",
|
||||
"loggingBehavior": "none",
|
||||
"webDir": "dist",
|
||||
"server": { "androidScheme": "https", "cleartext": false },
|
||||
"android": { "allowMixedContent": false, "webContentsDebuggingEnabled": false }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "aiturk-mobile",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"android:sync": "npm run build && cap sync android"
|
||||
},
|
||||
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1", "jsqr": "1.4.0", "qrcode-generator": "2.0.4" },
|
||||
"devDependencies": { "@capacitor/cli": "8.5.1" }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { chromium } from 'playwright'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile, mkdir } from 'node:fs/promises'
|
||||
import { extname, resolve } from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const root = resolve('dist')
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const path = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path))
|
||||
if (!file.startsWith(root + '\\') && !file.startsWith(root + '/')) throw new Error('outside assets')
|
||||
const data = await readFile(file)
|
||||
response.writeHead(200, { 'Content-Type': ({ '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css' })[extname(file)] || 'application/octet-stream' }); response.end(data)
|
||||
} catch { response.writeHead(404); response.end() }
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 })
|
||||
page.setDefaultTimeout(15000)
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message))
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||
await page.getByRole('heading', { name: 'Fikrini konuş. Birlikte geliştir.' }).waitFor()
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
assert.ok((await page.getByRole('button', { name: 'Eşleştirme kodu oluştur' }).boundingBox()).height >= 48)
|
||||
// UI wiring only: real account authentication is covered by gateway HTTP/DB
|
||||
// tests. Never submit a credential to the live portal from this smoke test.
|
||||
const loginCalls = []
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile-auth/**', async route => {
|
||||
const action = new URL(route.request().url()).pathname.split('/').at(-1); loginCalls.push(action)
|
||||
const result = action === 'start' ? { deviceCode: 'tsml_fixture', userCode: 'ABCDEFGH',
|
||||
verificationUri: 'https://turkservis.online/account/aiturk/mobile-login/11111111-1111-4111-8111-111111111111' }
|
||||
: action === 'poll' ? { state: 'approved', accountToken: 'tsml_fixture' } : { revoked: true }
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify(result) })
|
||||
})
|
||||
await page.getByRole('button', { name: 'AITURK hesabımla giriş yap' }).click()
|
||||
await page.getByText('Yalnız kendi hesabınızda bu kodu onaylayın: ABCDEFGH').waitFor()
|
||||
await page.getByRole('button', { name: 'Girişi kontrol et' }).click()
|
||||
await page.getByText('Hesaba giriş yapıldı.', { exact: true }).waitFor()
|
||||
let claimed = false
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/invites/**/claim', async route => {
|
||||
claimed = true
|
||||
const body = route.request().postDataJSON(); assert.equal(body.secret, 'a'.repeat(43))
|
||||
await route.fulfill({ status: 201, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: JSON.stringify({ device_id: '33333333-3333-4333-8333-333333333333', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
|
||||
})
|
||||
const qrImage = await page.evaluate(async () => {
|
||||
const { drawQr } = await import('./qr.mjs'), canvas = document.createElement('canvas')
|
||||
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
const desktopFingerprint = window.fixtureDesktopCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value
|
||||
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 2, desktopFingerprint, inviteId: '11111111-1111-4111-8111-111111111111',
|
||||
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(Date.now() + 600000).toISOString() }))
|
||||
return canvas.toDataURL('image/png').split(',')[1]
|
||||
})
|
||||
await page.locator('#qr-file').setInputFiles({ name: 'invite.png', mimeType: 'image/png', buffer: Buffer.from(qrImage, 'base64') })
|
||||
await page.getByText('Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: QRSTUVWX').waitFor()
|
||||
assert.equal(claimed, true)
|
||||
const phoneQr = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('phone-identity-qr')
|
||||
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
|
||||
return { decoded: jsQR(pixels.data, pixels.width, pixels.height)?.data,
|
||||
expected: document.getElementById('phone-identity').value, visible: !canvas.hidden }
|
||||
})
|
||||
assert.equal(phoneQr.visible, true)
|
||||
assert.equal(phoneQr.decoded, phoneQr.expected)
|
||||
// Run the actual app -> AccountClient -> WebRTC -> VoiceSession wiring.
|
||||
// Portal, IDE responses and Google frames are synthetic; no paid/API traffic.
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/devices', route => route.fulfill({
|
||||
json: { devices: [{ id: '22222222-2222-4222-8222-222222222222', name: 'Test bilgisayarı', platform: 'windows', self: false }] } }))
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/ice', route => route.fulfill({ json: { iceServers: [] } }))
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/channels**', async route => {
|
||||
if (route.request().method() === 'POST' && !route.request().url().endsWith('/heartbeat')) {
|
||||
const { sdp } = route.request().postDataJSON()
|
||||
await page.evaluate(async sdp => {
|
||||
const { completeIce, sendFrame } = await import('./transport.mjs')
|
||||
const { assertRemoteIdentity } = await import('./peer-identity.mjs')
|
||||
const trustedPhone = JSON.parse(document.getElementById('phone-identity').value)
|
||||
assertRemoteIdentity(sdp, trustedPhone.fingerprint)
|
||||
const pc = new RTCPeerConnection({ certificates: [window.fixtureDesktopCertificate] }); window.fixtureDesktop = pc; window.fixtureActions = []
|
||||
pc.ondatachannel = event => { event.channel.onmessage = incoming => {
|
||||
const request = JSON.parse(incoming.data); window.fixtureActions.push(request.action)
|
||||
const results = {
|
||||
'sessions.list': { sessions: [{ id: 'fixture-session', title: 'Test işi', model: 'fixture-model' }] },
|
||||
'session.open': { model: 'fixture-model', messages: [], approval: null, running: false },
|
||||
'voice.token': { token: 'fixture-only', model: 'gemini-3.1-flash-live-preview' },
|
||||
}
|
||||
sendFrame(event.channel, { id: request.id, result: results[request.action] || {} })
|
||||
} }
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp })
|
||||
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
||||
window.fixtureAnswer = pc.localDescription.sdp
|
||||
}, sdp)
|
||||
await route.fulfill({ json: {} })
|
||||
} else await route.fulfill({ json: { sdp: await page.evaluate(() => window.fixtureAnswer) } })
|
||||
})
|
||||
await page.evaluate(() => {
|
||||
window.fixtureVoiceSockets = []
|
||||
window.WebSocket = class {
|
||||
constructor(url) {
|
||||
if (!url.startsWith('wss://generativelanguage.googleapis.com/')) throw new Error('Unexpected test socket')
|
||||
this.readyState = 1; this.bufferedAmount = 0; window.fixtureVoiceSockets.push(this)
|
||||
queueMicrotask(() => this.onopen?.())
|
||||
}
|
||||
send(raw) {
|
||||
if (JSON.parse(raw).setup) queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ setupComplete: {} }) }))
|
||||
}
|
||||
close() { this.readyState = 3; this.onclose?.() }
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Onaydan sonra bilgisayarları bul' }).click()
|
||||
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||
await page.getByText('Bilgisayar bağlı. IDE konuşmasını seçin.', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: 'Konuşmaları yenile' }).click()
|
||||
await page.getByRole('button', { name: 'Bu konuşmayı seç', exact: true }).click()
|
||||
await page.getByText('IDE konuşması açıldı.', { exact: true }).waitFor()
|
||||
await page.locator('#consent').check()
|
||||
await page.locator('#voice-budget').fill('0.01')
|
||||
await page.locator('#voice-minutes').fill('3')
|
||||
await page.getByRole('button', { name: 'Asistanla yazış', exact: true }).click()
|
||||
await page.getByText('Google asistanı bağlı.', { exact: true }).waitFor()
|
||||
assert.match(await page.locator('#usage').textContent(), /henüz bilinmiyor/)
|
||||
await page.evaluate(() => window.fixtureVoiceSockets[0].onmessage({ data: JSON.stringify({ usageMetadata: {
|
||||
promptTokenCount: 1000, responseTokenCount: 1000, totalTokenCount: 2000,
|
||||
promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||
responseTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||
} }) }))
|
||||
await page.locator('#voice-status').filter({ hasText: 'maliyet sınırına ulaşıldı' }).waitFor()
|
||||
assert.match(await page.locator('#usage').textContent(), /0\.0150–0\.0150 USD/)
|
||||
assert.equal(await page.evaluate(() => window.fixtureVoiceSockets[0].readyState), 3)
|
||||
assert.deepEqual(await page.evaluate(() => window.fixtureActions), ['sessions.list', 'session.open', 'voice.token'])
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('aiturk.voice-limits'))), { minutes: 3, usd: 0.01 })
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
await mkdir('test-results', { recursive: true })
|
||||
await page.screenshot({ path: 'test-results/android-voice-limit.png', fullPage: true })
|
||||
await page.evaluate(() => window.fixtureDesktop.close())
|
||||
// A signaling service returning another machine's valid SDP must not replace
|
||||
// the identity captured from the user's QR or receive any IDE requests.
|
||||
await page.evaluate(async () => {
|
||||
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
})
|
||||
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||
await page.locator('#status').filter({ hasText: 'Cihaz kimliği doğrulanamadı' }).waitFor()
|
||||
assert.deepEqual(await page.evaluate(() => window.fixtureActions), [])
|
||||
await page.evaluate(() => window.fixtureDesktop.close())
|
||||
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
|
||||
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
|
||||
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
|
||||
await mkdir('test-results', { recursive: true })
|
||||
await page.screenshot({ path: 'test-results/android-onboarding.png', fullPage: true })
|
||||
const result = await page.evaluate(async () => {
|
||||
const { completeIce, DataRequests, sendFrame } = await import('./transport.mjs')
|
||||
const phone = new RTCPeerConnection(), desktop = new RTCPeerConnection()
|
||||
try {
|
||||
const channel = phone.createDataChannel('aiturk-mobile-v1'), requests = new DataRequests(channel, () => {})
|
||||
const remoteReady = new Promise(resolve => { desktop.ondatachannel = event => {
|
||||
event.channel.onmessage = incoming => {
|
||||
const request = JSON.parse(incoming.data)
|
||||
sendFrame(event.channel, { id: request.id, result: { action: request.action, text: 'Türkçe 🟢 '.repeat(24000) } })
|
||||
}
|
||||
event.channel.onopen = resolve
|
||||
} })
|
||||
await phone.setLocalDescription(await phone.createOffer()); await completeIce(phone)
|
||||
await desktop.setRemoteDescription(phone.localDescription)
|
||||
await desktop.setLocalDescription(await desktop.createAnswer()); await completeIce(desktop)
|
||||
await phone.setRemoteDescription(desktop.localDescription)
|
||||
await Promise.race([remoteReady, new Promise((_, reject) => setTimeout(() => reject(new Error('WebRTC timeout')), 15000))])
|
||||
if (channel.readyState !== 'open') await new Promise(resolve => channel.onopen = resolve)
|
||||
const response = await requests.call('session.history', { sessionId: 'fixture' })
|
||||
return { length: response.text.length, expected: 'Türkçe 🟢 '.repeat(24000).length, action: response.action,
|
||||
connectionState: phone.connectionState, dtls: phone.sctp.transport.state }
|
||||
} finally { phone.close(); desktop.close() }
|
||||
})
|
||||
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
|
||||
assert.deepEqual(errors, [])
|
||||
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed',
|
||||
appWebRTCWithSyntheticGoogleUsageLimit: 'passed', qrPinnedDesktopReplacement: 'rejected', realWebRTC: result, errors }))
|
||||
} finally { await browser.close(); server.close() }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { cp, mkdir } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
await mkdir(new URL('../dist', import.meta.url), { recursive: true })
|
||||
await cp(new URL('../src', import.meta.url), new URL('../dist', import.meta.url), { recursive: true })
|
||||
const core = dirname(createRequire(import.meta.url).resolve('@capacitor/core/package.json'))
|
||||
await cp(join(core, 'dist', 'capacitor.js'), new URL('../dist/capacitor.js', import.meta.url))
|
||||
await mkdir(new URL('../dist/vendor', import.meta.url), { recursive: true })
|
||||
await cp(new URL('../node_modules/qrcode-generator/dist/qrcode.mjs', import.meta.url), new URL('../dist/vendor/qrcode.mjs', import.meta.url))
|
||||
await cp(new URL('../node_modules/jsqr/dist/jsQR.js', import.meta.url), new URL('../dist/vendor/jsQR.js', import.meta.url))
|
||||
await cp(new URL('../node_modules/jsqr/LICENSE', import.meta.url), new URL('../dist/vendor/jsQR-LICENSE.txt', import.meta.url))
|
||||
console.log('AITURK Android web assets built.')
|
||||
@@ -0,0 +1,119 @@
|
||||
// Real identity-module + native DTLS test; full app pairing is tested separately.
|
||||
// Synthetic local peers, isolated browser context, no account or Google requests.
|
||||
import { chromium } from 'playwright'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.url === '/peer-identity.mjs') {
|
||||
try {
|
||||
const source = await readFile(new URL('../src/peer-identity.mjs', import.meta.url))
|
||||
response.writeHead(200, { 'Content-Type': 'text/javascript' }); response.end(source)
|
||||
} catch { response.writeHead(500); response.end() }
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
response.end('<!doctype html><title>Local certificate test</title>')
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage()
|
||||
page.setDefaultTimeout(20000)
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||
const original = await page.evaluate(async () => {
|
||||
const { createDeviceIdentity } = await import('/peer-identity.mjs')
|
||||
const results = await Promise.all(Array.from({ length: 4 }, () => createDeviceIdentity('11111111-1111-4111-8111-111111111111')))
|
||||
if (new Set(results.map(value => value.fingerprint)).size !== 1) throw new Error('Concurrent identity creation diverged')
|
||||
const { certificate, fingerprint } = results[0]
|
||||
return { fingerprints: certificate.getFingerprints(), fingerprint, expires: certificate.expires }
|
||||
})
|
||||
await page.reload()
|
||||
const result = await page.evaluate(async expected => {
|
||||
const { loadDeviceIdentity, assertRemoteIdentity } = await import('/peer-identity.mjs')
|
||||
const { certificate } = await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', expected)
|
||||
let missingRejected = false, wrongPinRejected = false
|
||||
try { await loadDeviceIdentity('22222222-2222-4222-8222-222222222222', expected) } catch { missingRejected = true }
|
||||
try { await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', Array(32).fill('00').join(':')) } catch { wrongPinRejected = true }
|
||||
const peers = []
|
||||
const complete = async pc => {
|
||||
if (pc.iceGatheringState === 'complete') return
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error('ICE timeout')) }, 10000)
|
||||
const check = () => { if (pc.iceGatheringState === 'complete') { cleanup(); resolve() } }
|
||||
const cleanup = () => { clearTimeout(timeout); pc.removeEventListener('icegatheringstatechange', check) }
|
||||
pc.addEventListener('icegatheringstatechange', check); check()
|
||||
})
|
||||
}
|
||||
try {
|
||||
const phone = new RTCPeerConnection({ iceServers: [] })
|
||||
const desktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||
peers.push(phone, desktop)
|
||||
const channel = phone.createDataChannel('certificate-probe')
|
||||
desktop.ondatachannel = event => { event.channel.onmessage = message => event.channel.send(message.data) }
|
||||
await phone.setLocalDescription(await phone.createOffer()); await complete(phone)
|
||||
await desktop.setRemoteDescription(phone.localDescription)
|
||||
await desktop.setLocalDescription(await desktop.createAnswer()); await complete(desktop)
|
||||
const advertised = desktop.localDescription.sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
|
||||
assertRemoteIdentity(desktop.localDescription.sdp, expected)
|
||||
await phone.setRemoteDescription(desktop.localDescription)
|
||||
const echo = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('DTLS echo timeout')), 15000)
|
||||
channel.onmessage = event => { clearTimeout(timeout); resolve(event.data) }
|
||||
channel.onopen = () => channel.send('Türkçe kimlik doğrulama 🟢')
|
||||
if (channel.readyState === 'open') channel.onopen()
|
||||
})
|
||||
const goodDtls = phone.sctp.transport.state
|
||||
const wrongCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
const wrongFingerprint = wrongCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value.toUpperCase()
|
||||
let untrustedPeerRejected = false
|
||||
try { assertRemoteIdentity(desktop.localDescription.sdp, wrongFingerprint) } catch { untrustedPeerRejected = true }
|
||||
const badPhone = new RTCPeerConnection({ iceServers: [] })
|
||||
const badDesktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||
peers.push(badPhone, badDesktop)
|
||||
const badChannel = badPhone.createDataChannel('certificate-mismatch')
|
||||
let mismatchOpened = false
|
||||
badChannel.onopen = () => { mismatchOpened = true }
|
||||
await badPhone.setLocalDescription(await badPhone.createOffer()); await complete(badPhone)
|
||||
await badDesktop.setRemoteDescription(badPhone.localDescription)
|
||||
await badDesktop.setLocalDescription(await badDesktop.createAnswer()); await complete(badDesktop)
|
||||
await badPhone.setRemoteDescription({ type: 'answer', sdp: badDesktop.localDescription.sdp
|
||||
.split(/\r?\n/).map(line => line.startsWith('a=fingerprint:sha-256 ')
|
||||
? `a=fingerprint:sha-256 ${wrongFingerprint}` : line).join('\r\n') })
|
||||
const mismatchState = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error('No definitive certificate rejection')) }, 15000)
|
||||
const check = () => {
|
||||
const state = badPhone.sctp?.transport.state
|
||||
if (state === 'failed' || state === 'connected') { cleanup(); resolve(state) }
|
||||
}
|
||||
const cleanup = () => { clearTimeout(timeout); badPhone.removeEventListener('connectionstatechange', check) }
|
||||
badPhone.addEventListener('connectionstatechange', check); check()
|
||||
})
|
||||
return { fingerprints: certificate.getFingerprints(), expires: certificate.expires,
|
||||
nativeCertificate: certificate instanceof RTCCertificate, advertised, echo,
|
||||
dtls: goodDtls, mismatchState, mismatchOpened, missingRejected, wrongPinRejected, untrustedPeerRejected }
|
||||
} finally { for (const peer of peers) peer.close() }
|
||||
}, original.fingerprint)
|
||||
assert.equal(result.nativeCertificate, true)
|
||||
assert.deepEqual(result.fingerprints, original.fingerprints)
|
||||
assert.equal(result.expires, original.expires)
|
||||
assert.ok(result.expires > Date.now())
|
||||
const sha256 = original.fingerprints.find(item => item.algorithm === 'sha-256')
|
||||
assert.ok(sha256)
|
||||
assert.ok(result.advertised.some(line => line.toLowerCase() === `a=fingerprint:sha-256 ${sha256.value}`.toLowerCase()))
|
||||
assert.equal(result.echo, 'Türkçe kimlik doğrulama 🟢')
|
||||
assert.equal(result.dtls, 'connected')
|
||||
assert.equal(result.mismatchState, 'failed')
|
||||
assert.equal(result.mismatchOpened, false)
|
||||
assert.equal(result.missingRejected, true)
|
||||
assert.equal(result.wrongPinRejected, true)
|
||||
assert.equal(result.untrustedPeerRejected, true)
|
||||
console.log(JSON.stringify({ nativeCertificatePersistence: 'passed', reload: 'passed',
|
||||
restoredCertificateDtlsEcho: 'passed', nativeFingerprintMismatch: 'rejected',
|
||||
concurrentCreation: 'passed', missingKeyAndWrongPin: 'rejected', scope: 'identity module and native DTLS; not full app pairing' }))
|
||||
} finally {
|
||||
await browser?.close()
|
||||
server.close()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve, join } from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createMobileWindow } from '../../desktop/electron/mobile-host.mjs'
|
||||
import { MobileVault } from '../../desktop/electron/mobile-secrets.mjs'
|
||||
const directory = await mkdtemp(join(tmpdir(), 'aiturk-mobile-electron-'))
|
||||
app.setPath('userData', join(directory, 'electron-profile'))
|
||||
void app.whenReady().then(async () => {
|
||||
let window
|
||||
try {
|
||||
assert.ok(safeStorage.isEncryptionAvailable(), 'Windows encryption unavailable')
|
||||
window = createMobileWindow({ directory, assets: resolve('apps/desktop/dist'), show: false,
|
||||
resolveBackend: async () => { throw new Error('No live backend in vault smoke test') } })
|
||||
await new Promise(resolve => window.webContents.once('did-finish-load', resolve))
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
const before = await mobileHost.call('setup');
|
||||
await mobileHost.call('account.save', {token:'local-fake-account-for-encryption-test'});
|
||||
const after = await mobileHost.call('setup');
|
||||
return {before,after,heading:document.querySelector('h1').textContent};
|
||||
})()`)
|
||||
assert.equal(result.before.hasAccount, false); assert.equal(result.after.hasAccount, true)
|
||||
const blob = await readFile(join(directory, 'mobile', 'mobile-secrets.bin'))
|
||||
assert.ok(!blob.includes(Buffer.from('local-fake-account-for-encryption-test')))
|
||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||
assert.equal((await vault.load()).accountToken, 'local-fake-account-for-encryption-test')
|
||||
await vault.save({ ...await vault.load(), deviceId: '11111111-1111-4111-8111-111111111111' })
|
||||
const fingerprint = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { createDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||
const setup = await mobileHost.call('setup');
|
||||
const identity = await createDeviceIdentity(setup.deviceId);
|
||||
await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:identity.fingerprint});
|
||||
let rejected = false;
|
||||
try { await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:Array(32).fill('00').join(':')}); }
|
||||
catch { rejected = true; }
|
||||
if (!rejected) throw new Error('Changed identity silently accepted');
|
||||
return identity.fingerprint;
|
||||
})()`)
|
||||
assert.equal((await vault.load()).localFingerprint, fingerprint)
|
||||
const reloaded = new Promise(resolve => window.webContents.once('did-finish-load', resolve))
|
||||
window.webContents.reload(); await reloaded
|
||||
const restored = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { loadDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||
const setup = await mobileHost.call('setup');
|
||||
const identity = await loadDeviceIdentity(setup.deviceId, setup.localFingerprint);
|
||||
return identity.fingerprint;
|
||||
})()`)
|
||||
assert.equal(restored, fingerprint)
|
||||
const originalFetch = globalThis.fetch, phoneId = '33333333-3333-4333-8333-333333333333'
|
||||
const pairingId = '44444444-4444-4444-8444-444444444444', phonePin = Array(32).fill('AB').join(':')
|
||||
let advertisedPin = phonePin, approvals = 0
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const path = new URL(url).pathname
|
||||
if (path === `/v1/aiturk/pairings/${pairingId}/approve` && options.method === 'POST') {
|
||||
approvals++
|
||||
return Response.json({ device_id: phoneId, status: 'approved' })
|
||||
}
|
||||
if (path === '/v1/aiturk/mobile/channels') return Response.json({ channels: [{ id: 'fixture-channel',
|
||||
phone_id: phoneId, sdp: `v=0\r\na=fingerprint:sha-256 ${advertisedPin}\r\n` }] })
|
||||
throw new Error('Unexpected network request in isolated test')
|
||||
}
|
||||
try {
|
||||
const proof = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: phonePin })
|
||||
const qrImport = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { drawQr } = await import('./mobile/qr.mjs');
|
||||
const canvas = document.createElement('canvas'); drawQr(canvas, ${JSON.stringify(proof)});
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'phone-identity.png', {type:'image/png'}));
|
||||
const input = document.getElementById('phone-identity-file'); input.files = transfer.files;
|
||||
await input.onchange({target:input});
|
||||
return {pairingId:document.getElementById('pairing').value, proof:document.getElementById('phone-identity').value,
|
||||
status:document.getElementById('status').textContent};
|
||||
})()`)
|
||||
assert.equal(qrImport.pairingId, pairingId)
|
||||
assert.equal(qrImport.proof, proof)
|
||||
assert.match(qrImport.status, /henüz izin verilmedi/)
|
||||
assert.equal(approvals, 0)
|
||||
const accepted = await window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: proof })})`)
|
||||
assert.equal(accepted.device_id, phoneId)
|
||||
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||
const inbox = await window.webContents.executeJavaScript(`mobileHost.call('inbox')`)
|
||||
assert.equal(inbox.channels[0].trustedPhoneFingerprint, phonePin)
|
||||
advertisedPin = Array(32).fill('CD').join(':')
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('inbox')`), /kimliği/)
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('channel.answer', {channelId:'fixture-channel',sdp:'unused'})`), /kimliği/)
|
||||
const changed = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: advertisedPin })
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: changed })})`), /kimliği/)
|
||||
assert.equal(approvals, 1)
|
||||
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||
} finally { globalThis.fetch = originalFetch }
|
||||
await vault.save({})
|
||||
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', desktopIdentityReload: 'passed',
|
||||
silentIdentityReplacement: 'rejected', locallyApprovedPhonePin: 'passed',
|
||||
changedPhoneBeforeAnswer: 'rejected', identityQrImportWithoutApproval: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
|
||||
await writeFile('apps/mobile/test-results/electron-smoke.json', JSON.stringify(receipt))
|
||||
console.log(JSON.stringify(receipt))
|
||||
window.destroy(); app.exit(0)
|
||||
} catch (error) { console.error(error); window?.destroy(); app.exit(1) }
|
||||
})
|
||||