From a7cca7215f0e6d656e35eb8692c987448087d5d3 Mon Sep 17 00:00:00 2001 From: DenysShcheniatskyi <110409967+DenysShcheniatskyi@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:19:48 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=D0=9F=D0=BE=D1=80=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20macOS=20(Apple=20Silicon)=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ASR: на darwin ищу whisper-server в локальной Metal-сборке, PATH и Homebrew вместо Windows zip/tar.exe; подсказка brew install whisper-cpp в UI, scripts/build-whisper-mac.sh для сборки с Metal - Защита окон от захвата включена на darwin (NSWindowSharingNone) - Разрешения macOS: микрофон через askForMediaAccess, понятная ошибка и кнопки в настройках при отсутствии «Запись экрана и системного аудио»; getSources больше не зависает при отказе - PDF: заглушка DOMMatrix, если @napi-rs/canvas не грузится на целевой ОС - package.json: dmg arm64, dist:mac, hardened runtime + entitlements, NSMicrophone/NSScreenCapture/NSAudioCaptureUsageDescription - Тексты UI без DPAPI/Windows на macOS Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- package.json | 30 ++++- resources/entitlements.mac.plist | 17 +++ scripts/build-whisper-mac.sh | 20 ++++ src/main/asr/assets.ts | 42 ++++++- src/main/asr/manager.ts | 11 +- src/main/context/importers.ts | 48 ++++++++ src/main/diagnostics.ts | 10 +- src/main/index.ts | 30 +++-- src/main/media/permissions.ts | 36 ++++++ src/main/storage/secrets.ts | 6 +- src/main/windows/windows.ts | 12 +- src/preload/index.ts | 1 + src/renderer/dashboard/settings/AudioTab.tsx | 110 +++++++++++++----- src/renderer/dashboard/settings/Providers.tsx | 6 +- .../dashboard/settings/SettingsPage.tsx | 14 ++- src/renderer/lib/api.ts | 13 +++ src/renderer/lib/audio.ts | 18 ++- tests/mac-port.test.ts | 66 +++++++++++ tests/pdf-fallback.test.ts | 14 +++ 20 files changed, 438 insertions(+), 68 deletions(-) create mode 100644 resources/entitlements.mac.plist create mode 100755 scripts/build-whisper-mac.sh create mode 100644 src/main/media/permissions.ts create mode 100644 tests/mac-port.test.ts create mode 100644 tests/pdf-fallback.test.ts diff --git a/package-lock.json b/package-lock.json index ed84e51..384ef51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^1.3.0", + "@napi-rs/canvas": "^0.1.100", "mammoth": "^1.13.0", "onnxruntime-node": "^1.31.1", "pdfjs-dist": "^5.7.284", @@ -774,7 +775,6 @@ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", "license": "MIT", - "optional": true, "workspaces": [ "e2e/*" ], diff --git a/package.json b/package.json index b43467a..e8d4bdd 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "test": "vitest run", "dist": "electron-vite build && electron-builder --win nsis", "icon": "node scripts/make-icon.ts", - "dist:dir": "electron-vite build && electron-builder --win dir" + "dist:dir": "electron-vite build && electron-builder --win dir", + "dist:mac": "electron-vite build && electron-builder --mac dmg --arm64" }, "dependencies": { "@anthropic-ai/sdk": "^1.3.0", + "@napi-rs/canvas": "^0.1.100", "mammoth": "^1.13.0", "onnxruntime-node": "^1.31.1", "pdfjs-dist": "^5.7.284", @@ -51,7 +53,8 @@ "asarUnpack": [ "node_modules/tesseract.js/**", "node_modules/tesseract.js-core/**", - "node_modules/onnxruntime-node/**" + "node_modules/onnxruntime-node/**", + "node_modules/@napi-rs/**" ], "extraResources": [ { @@ -76,6 +79,29 @@ "shortcutName": "Copilot", "deleteAppDataOnUninstall": false }, + "mac": { + "category": "public.app-category.productivity", + "icon": "resources/icon.png", + "target": [ + { + "target": "dmg", + "arch": [ + "arm64" + ] + } + ], + "hardenedRuntime": true, + "entitlements": "resources/entitlements.mac.plist", + "entitlementsInherit": "resources/entitlements.mac.plist", + "extendInfo": { + "NSMicrophoneUsageDescription": "Copilot слушает микрофон, чтобы локально распознавать вашу речь во время интервью и встреч.", + "NSScreenCaptureUsageDescription": "Copilot использует запись экрана для захвата системного звука собеседника и для выбора области экрана с задачей.", + "NSAudioCaptureUsageDescription": "Copilot захватывает системный звук, чтобы локально распознавать речь собеседника." + } + }, + "dmg": { + "artifactName": "Copilot-${version}-${arch}.${ext}" + }, "electronLanguages": [ "ru", "uk", diff --git a/resources/entitlements.mac.plist b/resources/entitlements.mac.plist new file mode 100644 index 0000000..be6ba6b --- /dev/null +++ b/resources/entitlements.mac.plist @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.device.audio-input + + + diff --git a/scripts/build-whisper-mac.sh b/scripts/build-whisper-mac.sh new file mode 100755 index 0000000..ee9cb9d --- /dev/null +++ b/scripts/build-whisper-mac.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Builds whisper-server with Metal for Apple Silicon and installs it where Copilot looks first: +# ~/Library/Application Support/copilot/asr/metal/whisper-server +# Alternative without building: brew install whisper-cpp +set -euo pipefail + +TAG="${WHISPER_TAG:-b5454}" +DEST="${HOME}/Library/Application Support/copilot/asr/metal" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +command -v cmake >/dev/null || { echo "Нужен cmake: brew install cmake" >&2; exit 1; } + +git clone --depth 1 --branch "$TAG" https://github.com/ggml-org/whisper.cpp "$WORK/whisper.cpp" +cmake -S "$WORK/whisper.cpp" -B "$WORK/build" -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_EXAMPLES=ON +cmake --build "$WORK/build" --config Release -j --target whisper-server + +mkdir -p "$DEST" +install -m 755 "$WORK/build/bin/whisper-server" "$DEST/whisper-server" +echo "Готово: $DEST/whisper-server" diff --git a/src/main/asr/assets.ts b/src/main/asr/assets.ts index 865a675..41df272 100644 --- a/src/main/asr/assets.ts +++ b/src/main/asr/assets.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import { createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs' -import { join } from 'node:path' +import { accessSync, constants, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs' +import { delimiter, join } from 'node:path' import { spawn } from 'node:child_process' import type { FetchFn } from '../providers/adapter' @@ -56,6 +56,14 @@ export const BINARIES: Record/metal, then PATH, then Homebrew. */ + private macServerPath(): string | null { + const local = join(this.root, 'metal') + if (existsSync(local)) { + const found = findFile(local, 'whisper-server') + if (found && isExecutable(found)) return found + } + const dirs = [...(this.env.PATH ?? '').split(delimiter).filter(Boolean), ...MAC_BIN_DIRS] + for (const d of dirs) { + const p = join(d, 'whisper-server') + if (isExecutable(p)) return p + } + return null + } + async downloadModel(name: string, onProgress: (p: Progress) => void, signal?: AbortSignal): Promise { const m = MODELS[name] if (!m) throw new Error(`Неизвестная модель ${name}`) @@ -99,6 +125,7 @@ export class AsrAssets { } async downloadBinary(backend: AsrBackend, onProgress: (p: Progress) => void, signal?: AbortSignal): Promise { + if (this.platform !== 'win32') throw new Error(MAC_INSTALL_HINT) const b = BINARIES[backend] const zip = join(this.root, `${backend}.zip.part`) const sha = await this.download(b.url, zip, b.label, onProgress, signal) @@ -159,6 +186,15 @@ function findFile(dir: string, name: string): string | null { return null } +function isExecutable(p: string): boolean { + try { + accessSync(p, constants.X_OK) + return statSync(p).isFile() + } catch { + return false + } +} + function run(cmd: string, args: string[]): Promise { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { windowsHide: true }) diff --git a/src/main/asr/manager.ts b/src/main/asr/manager.ts index 1ad14db..14077f9 100644 --- a/src/main/asr/manager.ts +++ b/src/main/asr/manager.ts @@ -1,6 +1,6 @@ import type { AsrStatus, Lang, Source, TranscriptSegment } from '@shared/types' import type { FetchFn } from '../providers/adapter' -import { AsrAssets, MODELS, type AsrBackend } from './assets' +import { AsrAssets, MAC_INSTALL_HINT, MODELS, type AsrBackend } from './assets' import type { SileroGate } from './silero' import { applyVocabulary, diagnoseWhisperFailure, isHallucination, stripTags, WhisperSidecar } from './whisper' @@ -93,7 +93,14 @@ export class AsrManager { if (this.sidecar?.running) return true const server = this.assets.serverPath(this.cfg.backend) if (!server || !this.assets.hasModel(this.cfg.model)) { - this.st = { ...this.st, error: !server ? 'Не установлен движок распознавания' : `Нет модели ${MODELS[this.cfg.model]?.label ?? this.cfg.model}` } + this.st = { + ...this.st, + error: !server + ? process.platform === 'darwin' + ? MAC_INSTALL_HINT + : 'Не установлен движок распознавания' + : `Нет модели ${MODELS[this.cfg.model]?.label ?? this.cfg.model}` + } this.refreshStatus() return false } diff --git a/src/main/context/importers.ts b/src/main/context/importers.ts index 50535ee..89a7682 100644 --- a/src/main/context/importers.ts +++ b/src/main/context/importers.ts @@ -25,7 +25,55 @@ export async function extractText(filePath: string): Promise { } } +/** + * pdfjs runs `new DOMMatrix()` at module load and polyfills it from @napi-rs/canvas, whose native binary exists only + * for the platform npm installed on (a build made on another OS ships none). Text extraction never renders, so when the + * real class is missing I fall back to a stub instead of failing with "DOMMatrix is not defined". + */ +export async function ensureDomMatrix(load: () => Promise<{ DOMMatrix?: unknown }> = () => import('@napi-rs/canvas')): Promise { + const g = globalThis as { DOMMatrix?: unknown } + if (g.DOMMatrix) return + try { + const m = await load() + if (m.DOMMatrix) { + g.DOMMatrix = m.DOMMatrix + return + } + } catch { + // native binary missing for this platform — use the stub below + } + g.DOMMatrix = StubDOMMatrix +} + +class StubDOMMatrix { + a = 1 + b = 0 + c = 0 + d = 1 + e = 0 + f = 0 + constructor(init?: number[]) { + if (Array.isArray(init) && init.length >= 6) [this.a, this.b, this.c, this.d, this.e, this.f] = init + } + multiplySelf(): this { + return this + } + preMultiplySelf(): this { + return this + } + invertSelf(): this { + return this + } + translate(): this { + return this + } + scale(): this { + return this + } +} + async function extractPdf(data: Buffer): Promise { + await ensureDomMatrix() const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs') const doc = await pdfjs.getDocument({ data: new Uint8Array(data), useSystemFonts: true } as never).promise const pages: string[] = [] diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts index b41a159..9fee35c 100644 --- a/src/main/diagnostics.ts +++ b/src/main/diagnostics.ts @@ -26,9 +26,11 @@ export async function runDiagnostics(d: DiagDeps): Promise { add( 'secrets', - 'Хранилище ключей (DPAPI)', + 'Хранилище ключей', d.secretsOk, - d.secretsOk ? 'Ключи шифруются системой Windows под вашей учётной записью' : 'Шифрование недоступно — ключи не сохраняются' + d.secretsOk + ? `Ключи шифруются системой (${process.platform === 'darwin' ? 'Связка ключей macOS' : 'Windows DPAPI'}) под вашей учётной записью` + : 'Шифрование недоступно — ключи не сохраняются' ) const st = d.asr.status @@ -72,8 +74,8 @@ export async function runDiagnostics(d: DiagDeps): Promise { 'Защита окна подсказок от захвата', d.protectionSupported ? null : false, d.protectionSupported - ? 'Системный флаг WDA_EXCLUDEFROMCAPTURE доступен. Результат зависит от способа захвата — проверьте у получателя: начните демонстрацию экрана и попросите собеседника сказать, видно ли окно подсказок.' - : 'Нужна Windows 10 2004 или новее' + ? `Системный флаг ${process.platform === 'darwin' ? 'NSWindowSharingNone' : 'WDA_EXCLUDEFROMCAPTURE'} доступен. Результат зависит от способа захвата — проверьте у получателя: начните демонстрацию экрана и попросите собеседника сказать, видно ли окно подсказок.` + : 'Нужна Windows 10 2004 или новее, либо macOS' ) if (d.settings.offline) { diff --git a/src/main/index.ts b/src/main/index.ts index b5b3b17..6f12cd8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,6 +39,7 @@ import { extractText } from './context/importers' import { chunkText } from './context/retrieval' import { runDiagnostics } from './diagnostics' import { exportSession } from './export' +import { ensureMicrophone, mediaAccess, openPrivacyPane } from './media/permissions' import { checkEndpoint, NetGuard } from './net/guard' import { PRESETS } from './providers/presets' import { ProviderRegistry } from './providers/registry' @@ -187,7 +188,7 @@ function applyProtection(): void { !want ? { state: 'off', label: 'выкл.' } : !supported - ? { state: 'error', label: 'не поддерживается этой версией Windows' } + ? { state: 'error', label: 'не поддерживается этой версией системы' } : live ? { state: 'warn', label: 'флаг установлен — работает не со всеми программами захвата, проверьте у получателя' } : { state: 'warn', label: 'включится при показе подсказок' } @@ -504,15 +505,28 @@ function lockDownSessions(): void { if (!allowed) log('renderer_blocked', { url: u.split('?')[0] }) cb({ cancel: !allowed }) }) - session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => { - cb(permission === 'media' || permission === 'display-capture' || permission === 'clipboard-sanitized-write') + session.defaultSession.setPermissionRequestHandler((_wc, permission, cb, details) => { + if (permission === 'media') { + const wantsMic = 'mediaTypes' in details && (details.mediaTypes ?? []).includes('audio') + if (wantsMic) return void ensureMicrophone().then(cb) + return cb(true) + } + cb(permission === 'display-capture' || permission === 'clipboard-sanitized-write') }) session.defaultSession.setDisplayMediaRequestHandler( (_req, cb) => { - void desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { - // I use only the audio loopback; the renderer stops the video track immediately. - cb(sources[0] ? { video: sources[0], audio: 'loopback' } : {}) - }) + // On macOS the first getSources() registers the app under «Screen & System Audio Recording» and shows the prompt; + // until it is granted the call fails or returns nothing, and the renderer reports the missing permission. + desktopCapturer + .getSources({ types: ['screen'] }) + .then((sources) => { + // I use only the audio loopback; the renderer stops the video track immediately. + cb(sources[0] ? { video: sources[0], audio: 'loopback' } : {}) + }) + .catch((e: unknown) => { + log('display_media_fail', { message: e instanceof Error ? e.message : String(e), access: mediaAccess().screen }) + cb({}) + }) }, { useSystemPicker: false } ) @@ -741,6 +755,8 @@ function registerIpc(): void { }) ) handle('km:diagnostics:log', () => db.recentDiag(100)) + handle('km:media:access', () => mediaAccess()) + handle('km:media:openPrivacy', (kind: 'microphone' | 'screen') => openPrivacyPane(kind)) handle('km:open', (url: string) => { if (/^https:\/\//.test(url)) void shell.openExternal(url) }) diff --git a/src/main/media/permissions.ts b/src/main/media/permissions.ts new file mode 100644 index 0000000..0f912d0 --- /dev/null +++ b/src/main/media/permissions.ts @@ -0,0 +1,36 @@ +import { shell, systemPreferences } from 'electron' + +export type MediaAccess = 'granted' | 'denied' | 'restricted' | 'not-determined' | 'unknown' + +export interface MediaAccessReport { + /** false where the OS does not gate capture per app (Windows), so the UI shows nothing. */ + gated: boolean + microphone: MediaAccess + screen: MediaAccess +} + +// Privacy panes in System Settings; the anchors have been stable since macOS 10.15. +const PANES = { + microphone: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture' +} as const + +export function mediaAccess(platform: NodeJS.Platform = process.platform): MediaAccessReport { + if (platform !== 'darwin') return { gated: false, microphone: 'granted', screen: 'granted' } + return { + gated: true, + microphone: systemPreferences.getMediaAccessStatus('microphone'), + screen: systemPreferences.getMediaAccessStatus('screen') + } +} + +/** macOS shows the microphone prompt only once; later calls just report the stored answer. */ +export async function ensureMicrophone(): Promise { + if (process.platform !== 'darwin') return true + if (systemPreferences.getMediaAccessStatus('microphone') === 'granted') return true + return systemPreferences.askForMediaAccess('microphone') +} + +export function openPrivacyPane(kind: keyof typeof PANES): void { + if (process.platform === 'darwin') void shell.openExternal(PANES[kind]) +} diff --git a/src/main/storage/secrets.ts b/src/main/storage/secrets.ts index 24b9c32..354ca36 100644 --- a/src/main/storage/secrets.ts +++ b/src/main/storage/secrets.ts @@ -8,8 +8,8 @@ export interface Cipher { } /** - * I never let keys touch settings.json or SQLite. The file holds only DPAPI blobs (Electron safeStorage), - * which decrypt solely under the same Windows user. + * I never let keys touch settings.json or SQLite. The file holds only Electron safeStorage blobs + * (DPAPI on Windows, a Keychain-held key on macOS), which decrypt solely under the same OS user. */ export class SecretStore { private blobs: Record = {} @@ -50,7 +50,7 @@ export class SecretStore { } set(id: string, value: string): void { - if (!this.cipher.available()) throw new Error('Системное шифрование (DPAPI) недоступно — ключ не сохранён') + if (!this.cipher.available()) throw new Error('Системное шифрование недоступно — ключ не сохранён') const old = this.get(id) if (old) unregisterSecret(old) this.blobs[id] = this.cipher.encrypt(value).toString('base64') diff --git a/src/main/windows/windows.ts b/src/main/windows/windows.ts index e010b62..a76b8ac 100644 --- a/src/main/windows/windows.ts +++ b/src/main/windows/windows.ts @@ -37,10 +37,14 @@ export function createDashboard(): BrowserWindow { return win } -/** WDA_EXCLUDEFROMCAPTURE exists since Windows 10 2004 (build 19041). */ -export function displayProtectionSupported(): boolean { - if (process.platform !== 'win32') return false - const build = Number(release().split('.')[2] ?? 0) +/** + * WDA_EXCLUDEFROMCAPTURE exists since Windows 10 2004 (build 19041). On macOS Electron sets NSWindowSharingNone, + * which every supported version accepts, though ScreenCaptureKit-based tools may still capture the window. + */ +export function displayProtectionSupported(platform: NodeJS.Platform = process.platform, osRelease = release()): boolean { + if (platform === 'darwin') return true + if (platform !== 'win32') return false + const build = Number(osRelease.split('.')[2] ?? 0) return build >= 19041 } diff --git a/src/preload/index.ts b/src/preload/index.ts index 01511cc..0c158a2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,7 @@ function on(channel: string) { const api = { invoke, + platform: process.platform, onSnapshot: on('km:snapshot'), onSettings: on('km:settings'), onAsr: on('km:asr'), diff --git a/src/renderer/dashboard/settings/AudioTab.tsx b/src/renderer/dashboard/settings/AudioTab.tsx index a91ece2..7cd96bb 100644 --- a/src/renderer/dashboard/settings/AudioTab.tsx +++ b/src/renderer/dashboard/settings/AudioTab.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' import type { AsrStatus, Lang, Source } from '@shared/types' -import { api } from '../../lib/api' +import { api, isMac, type MediaAccessReport } from '../../lib/api' import { probeLevel } from '../../lib/audio' import type { Ctx } from '../App' import { ErrorText, Meter, Toggle } from '../ui' -const ASR_OPTIONS = [ +const WIN_ASR_OPTIONS = [ { backend: 'cpu' as const, model: 'base', title: 'Быстрый (процессор)', size: '≈160 МБ', note: '≈1 с на фразу на CPU. Подходит всем.' }, { backend: 'cpu' as const, model: 'small', title: 'Точнее (процессор)', size: '≈500 МБ', note: '≈2 с на фразу — медленнее, но лучше на акцентах.' }, { @@ -17,6 +17,15 @@ const ASR_OPTIONS = [ } ] +// whisper.cpp on Apple Silicon runs on Metal regardless of backend, so I keep 'cpu' as the stored value and drop CUDA. +const MAC_ASR_OPTIONS = [ + { backend: 'cpu' as const, model: 'base', title: 'Быстрый', size: '≈150 МБ', note: 'Metal на Apple Silicon — доли секунды на фразу.' }, + { backend: 'cpu' as const, model: 'small', title: 'Точнее', size: '≈490 МБ', note: 'Лучше на акцентах, всё ещё быстро на M-серии.' }, + { backend: 'cpu' as const, model: 'turbo', title: 'Максимум', size: '≈580 МБ', note: 'Large v3 Turbo — лучшая точность. Первый запуск прогревается.' } +] + +const ASR_OPTIONS = isMac ? MAC_ASR_OPTIONS : WIN_ASR_OPTIONS + // A fixed language is ~1.5× faster on CPU (spike S2, audit 2026-10-28) and never turns speech into a translation. export const ASR_LANG_OPTIONS = ( <> @@ -32,7 +41,7 @@ export function AsrInstaller({ ctx, asr, compact }: { ctx: Ctx; asr: AsrStatus | const [err, setErr] = useState(null) const [busy, setBusy] = useState(false) useEffect(() => { - void api.hasNvidia().then(setNvidia) + if (!isMac) void api.hasNvidia().then(setNvidia) }, []) const cur = ctx.settings.asr const dl = asr?.download @@ -51,6 +60,12 @@ export function AsrInstaller({ ctx, asr, compact }: { ctx: Ctx; asr: AsrStatus | return (
+ {isMac && asr && !asr.binary && ( +
+ На macOS движок ставится отдельно: brew install whisper-cpp (собран с Metal) или scripts/build-whisper-mac.sh. Модель + приложение скачает само. +
+ )}
{ASR_OPTIONS.filter((o) => !compact || o.model !== 'small').map((o) => { const active = cur.backend === o.backend && cur.model === o.model @@ -87,6 +102,36 @@ export function AsrInstaller({ ctx, asr, compact }: { ctx: Ctx; asr: AsrStatus | ) } +/** macOS gates mic and system audio per app; I show what is missing and a shortcut to the right Settings pane. */ +function MacPermissions() { + const [acc, setAcc] = useState(null) + useEffect(() => { + const load = () => void api.media.access().then(setAcc) + load() + window.addEventListener('focus', load) + return () => window.removeEventListener('focus', load) + }, []) + if (!acc?.gated) return null + const missing = (['microphone', 'screen'] as const).filter((k) => acc[k] !== 'granted' && acc[k] !== 'not-determined') + if (!missing.length) return null + return ( +
+ + macOS не дал доступ:{' '} + {missing.map((k) => (k === 'microphone' ? '«Микрофон»' : '«Запись экрана и системного аудио» (нужна для звука собеседника)')).join(', ')}. После + включения перезапустите Copilot. + +
+ {missing.map((k) => ( + + ))} +
+
+ ) +} + function Probe({ source, deviceId }: { source: Source; deviceId: string | null }) { const [level, setLevel] = useState(0) const [verdict, setVerdict] = useState(null) @@ -102,7 +147,7 @@ function Probe({ source, deviceId }: { source: Source; deviceId: string | null } : max > 0.004 ? 'Очень тихо — проверьте громкость.' : source === 'user' - ? 'Тишина. Проверьте выбранный микрофон и его громкость в Windows.' + ? `Тишина. Проверьте выбранный микрофон и его громкость в ${isMac ? 'Системных настройках → Звук' : 'Windows'}.` : 'Тишина. Включите звук в звонке/видео во время проверки.' ) } catch (e) { @@ -145,32 +190,39 @@ export function DeviceSettings({ ctx }: { ctx: Ctx }) { }, []) const a = ctx.settings.audio return ( -
-
- void ctx.patch({ audio: { captureMic: v } })} label="Мой микрофон" hint="Ваша речь — источник «user»" /> - - -
-
- void ctx.patch({ audio: { captureSystem: v } })} - label="Звук собеседника (системный звук)" - hint="WASAPI loopback: весь звук, который играет Windows. Захват отдельного приложения пока не поддерживается." - /> -
Совет: наушники убирают эхо собеседника в микрофоне.
- + <> + {isMac && } +
+
+ void ctx.patch({ audio: { captureMic: v } })} label="Мой микрофон" hint="Ваша речь — источник «user»" /> + + +
+
+ void ctx.patch({ audio: { captureSystem: v } })} + label="Звук собеседника (системный звук)" + hint={ + isMac + ? 'Весь системный звук через ScreenCaptureKit — нужен доступ «Запись экрана». Захват отдельного приложения пока не поддерживается.' + : 'WASAPI loopback: весь звук, который играет Windows. Захват отдельного приложения пока не поддерживается.' + } + /> +
Совет: наушники убирают эхо собеседника в микрофоне.
+ +
-
+ ) } diff --git a/src/renderer/dashboard/settings/Providers.tsx b/src/renderer/dashboard/settings/Providers.tsx index 215e652..5758c12 100644 --- a/src/renderer/dashboard/settings/Providers.tsx +++ b/src/renderer/dashboard/settings/Providers.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import type { ModelInfo, ModelRole, ProviderConfig, ProviderPreset } from '@shared/types' -import { api, type PresetInfo, type ProviderTestReport } from '../../lib/api' +import { api, isMac, type PresetInfo, type ProviderTestReport } from '../../lib/api' import { useAsync } from '../../lib/hooks' import type { Ctx } from '../App' import { ErrorText, Toggle } from '../ui' @@ -105,7 +105,7 @@ export function ProviderForm({ label="Разрешить как запасной" hint="Если основной провайдер недоступен (429/5xx/таймаут), запрос можно отправить сюда. Без этой галочки данные сюда не попадут." /> -
Ключ шифруется Windows (DPAPI) и не попадает в настройки, логи и экспорт.
+
Ключ шифруется системным хранилищем ({isMac ? 'Связка ключей' : 'DPAPI'}) и не попадает в настройки, логи и экспорт.
{warn &&
{warn}
}
@@ -280,7 +280,7 @@ export function ProvidersTab({ ctx }: { ctx: Ctx }) { {p.label} {p.baseUrl} - {p.hasKey && ключ в DPAPI} + {p.hasKey && ключ зашифрован} {p.allowFallback && запасной} ))} {list && !list.length && (
- Ничего не найдено + {t('Ничего не найдено')}
)}
{!detail ? ( -
Выберите сессию слева.
+
{t('Выберите сессию слева.')}
) : ( <>

{String(detail.session?.title ?? '')}

- -
@@ -99,11 +100,11 @@ export function History() { ))}
))} -

Транскрипт

+

{t('Транскрипт')}

{detail.segments.map((s) => (
- {s.source === 'remote' ? 'Собеседник' : 'Я'}: {String(s.text)} + {s.source === 'remote' ? t('Собеседник') : t('Я')}: {String(s.text)}
))}
diff --git a/src/renderer/dashboard/Home.tsx b/src/renderer/dashboard/Home.tsx index bf1afcf..51b9dbd 100644 --- a/src/renderer/dashboard/Home.tsx +++ b/src/renderer/dashboard/Home.tsx @@ -23,7 +23,7 @@ export function Home({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) { {snap.notice && !snap.sessionId &&
{snap.notice}
}
-

{snap.sessionId ? 'Сессия идёт' : 'Готовы к разговору?'}

+

{snap.sessionId ? t('Сессия идёт') : t('Готовы к разговору?')}

{( [ @@ -39,16 +39,16 @@ export function Home({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) {
{mode === 'practice' - ? 'Вопросы можно читать вслух самому или вводить текстом — подсказки на ваш голос тоже.' + ? t('Вопросы можно читать вслух самому или вводить текстом — подсказки на ваш голос тоже.') : mode === 'live' - ? 'Подсказки только на речь собеседника (системный звук). Ваш голос — в транскрипт и контекст.' - : 'Без подсказок: транскрипт встречи и вопросы сохраняются локально.'} + ? t('Подсказки только на речь собеседника (системный звук). Ваш голос — в транскрипт и контекст.') + : t('Без подсказок: транскрипт встречи и вопросы сохраняются локально.')}
{snap.sessionId ? ( <>
-

Горячие клавиши

+

{t('Горячие клавиши')}

- Показать/скрыть подсказки + {t('Показать/скрыть подсказки')} {pretty(keys.toggleOverlay)} - Ответить сейчас + {t('Ответить сейчас')} {pretty(keys.answerNow)} - Пауза / продолжить + {t('Пауза / продолжить')} {pretty(keys.toggleListen)} - Снимок области экрана + {t('Снимок области экрана')} {pretty(keys.snapRegion)} - Развернуть / подробно + {t('Развернуть / подробно')} {pretty(keys.expand)} - Аварийная остановка + {t('Аварийная остановка')} {pretty(keys.panic)}
@@ -165,7 +165,7 @@ export function Home({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) {

{t('Последние сессии')}

{sessions?.length ? ( @@ -176,13 +176,15 @@ export function Home({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) { {fmtDate(x.startedAt)} {x.title} - {x.questions} вопр. - {x.status === 'interrupted' && прервана} + {t('{n} вопр.', { n: x.questions })} + {x.status === 'interrupted' && {t('прервана')}}
))}
) : ( -
Пока нет. {s.history && s.consent.history ? '' : 'Сохранение истории выключено.'}
+
+ {t('Пока нет.')} {s.history && s.consent.history ? '' : t('Сохранение истории выключено.')} +
)}
diff --git a/src/renderer/dashboard/Live.tsx b/src/renderer/dashboard/Live.tsx index fd4f8af..7ce5293 100644 --- a/src/renderer/dashboard/Live.tsx +++ b/src/renderer/dashboard/Live.tsx @@ -12,10 +12,10 @@ function Prov({ a }: { a?: AnswerEvent }) { return (
{a.provider} · {a.model} - {a.firstTokenMs != null && ` · первый токен ${(a.firstTokenMs / 1000).toFixed(1)} с`} + {a.firstTokenMs != null && ` · ${t('первый токен {s} с', { s: (a.firstTokenMs / 1000).toFixed(1) })}`} {a.status === 'done' && - (a.usageCostUsd == null ? ` · ${t('Цена неизвестна')}` : a.usageCostUsd === 0 ? ' · локально, бесплатно' : ` · $${a.usageCostUsd.toFixed(4)}`)} - {a.usage && !a.usage.exact && ' · токены оценочно'} + (a.usageCostUsd == null ? ` · ${t('Цена неизвестна')}` : a.usageCostUsd === 0 ? ` · ${t('локально, бесплатно')}` : ` · $${a.usageCostUsd.toFixed(4)}`)} + {a.usage && !a.usage.exact && ` · ${t('токены оценочно')}`}
) } @@ -56,9 +56,9 @@ export function Live({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) {
- Я + {t('Я')} - Собеседник + {t('Собеседник')}
)} @@ -156,11 +162,12 @@ export function Live({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) {
- {t('Вопрос')} {q.revision > 1 ? `· ревизия ${q.revision}` : ''} · {q.source === 'remote' ? 'собеседник' : 'вы'} · {q.kind} + {t('Вопрос')} {q.revision > 1 ? `· ${t('ревизия {n}', { n: q.revision })}` : ''} · {q.source === 'remote' ? t('собеседник') : t('вы')} ·{' '} + {q.kind} {editing === null ? ( - ) : null}
@@ -174,7 +181,7 @@ export function Live({ ctx, snap }: { ctx: Ctx; snap: AppSnapshot }) { onKeyDown={(e) => e.key === 'Enter' && (void api.session.edit(editing), setEditing(null))} /> {' '} - или {pretty(ctx.settings.shortcuts.answerNow)} + {t('или')} {pretty(ctx.settings.shortcuts.answerNow)}
)} ) : (
❝
- {snap.sessionId ? 'Как только прозвучит вопрос — здесь появится ответ.' : 'Начните сессию или задайте вопрос текстом слева.'} + {snap.sessionId ? t('Как только прозвучит вопрос — здесь появится ответ.') : t('Начните сессию или задайте вопрос текстом слева.')}
)}
- Расход сессии: {snap.cost.usd == null ? '—' : `$${snap.cost.usd.toFixed(4)}`} · запросов {snap.cost.requests} - {snap.cost.unknownPriced > 0 && ` · ${snap.cost.unknownPriced} без цены (лимит $ для них не контролируется)`} + {t('Расход сессии: {usd} · запросов {n}', { usd: snap.cost.usd == null ? '—' : `$${snap.cost.usd.toFixed(4)}`, n: snap.cost.requests })} + {snap.cost.unknownPriced > 0 && ` · ${t('{n} без цены (лимит $ для них не контролируется)', { n: snap.cost.unknownPriced })}`}
diff --git a/src/renderer/dashboard/Onboarding.tsx b/src/renderer/dashboard/Onboarding.tsx index 8afb4b0..a541a1b 100644 --- a/src/renderer/dashboard/Onboarding.tsx +++ b/src/renderer/dashboard/Onboarding.tsx @@ -32,9 +32,7 @@ export function Onboarding({ ctx, asr }: { ctx: Ctx; asr: AsrStatus | null }) { Copilot
- - Шаг {step + 1} из {STEPS.length} - + {t('Шаг {n} из {total}', { n: step + 1, total: STEPS.length })}
{STEPS.map((s, i) => ( @@ -82,12 +80,13 @@ function StepLang({ ctx }: { ctx: Ctx }) {

{t('Добро пожаловать в Copilot')}

- Помощник слушает разговор, вовремя замечает вопрос и показывает короткий ответ с опорой на ваше резюме. Распознавание речи — на этом компьютере, модель - для ответов вы выбираете сами. + {t( + 'Помощник слушает разговор, вовремя замечает вопрос и показывает короткий ответ с опорой на ваше резюме. Распознавание речи — на этом компьютере, модель для ответов вы выбираете сами.' + )}

-
Займёт около 3 минут. Любой шаг можно пропустить и настроить позже.
+
{t('Займёт около 3 минут. Любой шаг можно пропустить и настроить позже.')}
) } @@ -125,9 +124,9 @@ function StepLang({ ctx }: { ctx: Ctx }) { function StepAudio({ ctx }: { ctx: Ctx }) { return (
-

Звук: вы и собеседник — раздельно

+

{t('Звук: вы и собеседник — раздельно')}

- Микрофон — это вы. Системный звук — голос собеседника из Zoom, Meet, Teams или браузера. Подсказки строятся по речи собеседника. + {t('Микрофон — это вы. Системный звук — голос собеседника из Zoom, Meet, Teams или браузера. Подсказки строятся по речи собеседника.')}

@@ -137,13 +136,14 @@ function StepAudio({ ctx }: { ctx: Ctx }) { function StepAsr({ ctx, asr }: { ctx: Ctx; asr: AsrStatus | null }) { return (
-

Распознавание речи

+

{t('Распознавание речи')}

- Работает локально (whisper.cpp): аудио не покидает компьютер. Нужно один раз скачать движок и модель — размер и проверка контрольной суммы показаны - ниже. + {t( + 'Работает локально (whisper.cpp): аудио не покидает компьютер. Нужно один раз скачать движок и модель — размер и проверка контрольной суммы показаны ниже.' + )}

-
Без распознавания можно вводить вопросы текстом.
+
{t('Без распознавания можно вводить вопросы текстом.')}
) } @@ -165,9 +165,9 @@ function StepModel({ ctx }: { ctx: Ctx }) { return (
-

Модель для ответов

+

{t('Модель для ответов')}

- Свой ключ (BYOK) или локальная модель. Ollama работает без интернета и без ключа. + {t('Свой ключ (BYOK) или локальная модель. Ollama работает без интернета и без ключа.')}

{(!providers?.length || adding) && ( {p.label}
void choose(p, m)} /> @@ -200,11 +200,11 @@ function StepModel({ ctx }: { ctx: Ctx }) { } }} > - {busy ? 'Проверяю текст и изображение…' : 'Проверить соединение'} + {busy ? t('Проверяю текст и изображение…') : t('Проверить соединение')} {test?.vision && !test.vision.ok && ( -
Снимки экрана будут распознаваться локальным OCR. Отдельную vision-модель можно выбрать в настройках.
+
{t('Снимки экрана будут распознаваться локальным OCR. Отдельную vision-модель можно выбрать в настройках.')}
)}
)} @@ -224,11 +224,11 @@ function StepProfile({ ctx }: { ctx: Ctx }) { } return (
-

Профиль

+

{t('Профиль')}

{(Object.keys(PRESET_LABEL) as Profile['preset'][]).map((k) => ( ))}
@@ -245,7 +245,7 @@ function StepProfile({ ctx }: { ctx: Ctx }) { } }} > - Загрузить резюме (PDF/DOCX/TXT) + {t('Загрузить резюме (PDF/DOCX/TXT)')}
{err &&
{err}
} {docs?.map((d) => (
- ✓ {d.name} — {d.chunks} фрагм. + ✓ {d.name} — {t('{n} фрагм.', { n: d.chunks })}
))} -
Ответы о вашем опыте строятся только на загруженных фактах. Чего нет в резюме, приложение не придумывает.
+
{t('Ответы о вашем опыте строятся только на загруженных фактах. Чего нет в резюме, приложение не придумывает.')}
) } @@ -289,9 +289,9 @@ function StepTest({ ctx }: { ctx: Ctx }) { const a = snap?.answers.say return (
-

Проверка за 30 секунд

+

{t('Проверка за 30 секунд')}

- Нажмите «Начать» и задайте вопрос вслух, например: «Что такое идемпотентность в REST?» — или введите его. Появится окно подсказок. + {t('Нажмите «Начать» и задайте вопрос вслух, например: «Что такое идемпотентность в REST?» — или введите его. Появится окно подсказок.')}

{snap && (
-
Распознано: {snap.partial.user || snap.segments.at(-1)?.text || '—'}
+
+ {t('Распознано:')} {snap.partial.user || snap.segments.at(-1)?.text || '—'} +
{snap.activeQuestion && {snap.activeQuestion.text}} {a && (
{a.text || '…'} {a.firstTokenMs != null && (
- {a.provider} · {a.model} · первый токен {(a.firstTokenMs / 1000).toFixed(1)} с + {a.provider} · {a.model} · {t('первый токен {s} с', { s: (a.firstTokenMs / 1000).toFixed(1) })}
)} {a.error && ( @@ -353,30 +355,31 @@ function StepConsent({ ctx, started }: { ctx: Ctx; started: number }) { } return (
-

Последнее: согласие и честность

+

{t('Последнее: согласие и честность')}

- Записывать звук и использовать AI-подсказки можно только там, где это разрешено правилами и участники согласны. Copilot не обходит прокторинг и средства - контроля и не обещает невидимости. + {t( + 'Записывать звук и использовать AI-подсказки можно только там, где это разрешено правилами и участники согласны. Copilot не обходит прокторинг и средства контроля и не обещает невидимости.' + )}
void ctx.patch({ consent: { history: v } })} - label="Сохранять историю сессий на этом компьютере" - hint="Можно удалить в любой момент. Без истории нет восстановления после сбоя." + label={t('Сохранять историю сессий на этом компьютере')} + hint={t('Можно удалить в любой момент. Без истории нет восстановления после сбоя.')} /> - void ctx.patch({ consent: { screen: v } })} label="Разрешить снимки экрана по моей команде" /> + void ctx.patch({ consent: { screen: v } })} label={t('Разрешить снимки экрана по моей команде')} /> void ctx.patch({ consent: { cloud: v } })} - label="Разрешить отправку в облачную модель" - hint="Нужно, только если модель для ответов — не на этом компьютере. Уходит вопрос, недавний транскрипт и подходящие фрагменты документов." + label={t('Разрешить отправку в облачную модель')} + hint={t('Нужно, только если модель для ответов — не на этом компьютере. Уходит вопрос, недавний транскрипт и подходящие фрагменты документов.')} />
- Настройка заняла {Math.max(1, Math.round((Date.now() - started) / 60000))} мин. + {t('Настройка заняла {n} мин.', { n: Math.max(1, Math.round((Date.now() - started) / 60000)) })} diff --git a/src/renderer/dashboard/ProfilePage.tsx b/src/renderer/dashboard/ProfilePage.tsx index c24ddba..da0cf4d 100644 --- a/src/renderer/dashboard/ProfilePage.tsx +++ b/src/renderer/dashboard/ProfilePage.tsx @@ -2,9 +2,11 @@ import { useEffect, useState } from 'react' import type { KnowledgeDoc, Profile } from '@shared/types' import { api } from '../lib/api' import { useAsync } from '../lib/hooks' +import { t } from '../lib/i18n' import type { Ctx } from './App' import { ErrorText } from './ui' +// Russian keys: these maps are built before the window sets its language, so callers pass the value through t(). export const PRESET_LABEL: Record = { hr: 'HR / поведенческое', backend: 'Backend', @@ -60,7 +62,7 @@ export function ProfilePage({ ctx }: { ctx: Ctx }) { return (
-

Профиль и документы

+

{t('Профиль и документы')}

setDraft({ ...draft, name: e.target.value })} />