Разрешения: шаги и кнопка «Открыть настройки» вместо системной ошибки
На Mac разрешение «Запись экрана и системного звука» запоминается за конкретной сборкой программы (сборки подписаны ad-hoc): после обновления Skillok Meet в списке включён, а новой версии macOS прав не даёт. - open_privacy: открывает нужный раздел настроек — «Запись экрана и системного звука» или «Микрофон» на macOS, «Микрофон» в Windows. - Проверка звука и панель записи: вместо текста ошибки — что делать, с объяснением ловушки «включено, но от прошлой версии — удалите «−» и разрешите заново», и кнопка в настройки; текст системы — мелко ниже. - CheckView.os. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e581846
· сравнить с родителем
-
CHANGELOG.md +10
Показать разницуСкрыть разницу
1 1 # Изменения 2 2 3 +## 1.5 — не выпущена 4 + 5 +### Улучшено 6 + 7 +- **Звук компьютера не пишется на Mac — понятно, что делать.** В проверке звука и на 8 + панели записи вместо системной ошибки — шаги и кнопка «Открыть настройки macOS», которая 9 + сразу открывает «Запись экрана и системного звука». Отдельно объяснена главная ловушка: 10 + после обновления Skillok Meet в списке остаётся включённым, но это разрешение прошлой 11 + версии — его надо удалить кнопкой «−» и разрешить заново. Для микрофона — та же кнопка. 12 + 3 13 ## 1.4 — 02.10.2026 4 14 5 15 Главное: **запись можно слушать** — проигрыватель с «волной» всей встречи и переходом к -
Показать разницуСкрыть разницу
804 804 mic_error: Option<String>, 805 805 /// Почему не открылся звук компьютера — подсказать, где разрешить. 806 806 system_error: Option<String>, 807 + /// `macos`, `windows` или другое — подсказки и кнопка настроек у систем разные. 808 + os: &'static str, 809 +} 810 + 811 +/// Открыть раздел настроек системы, где выдаются разрешения: `screen` — 812 +/// «Запись экрана и системного звука» (macOS), `microphone` — «Микрофон». 813 +/// Описать путь словами мало: человек теряется в настройках, а разрешение — 814 +/// то место, где запись чаще всего ломается. 815 +#[tauri::command] 816 +pub fn open_privacy(what: String) -> Result<(), String> { 817 + let url = if cfg!(target_os = "macos") { 818 + match what.as_str() { 819 + "screen" => "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", 820 + _ => "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", 821 + } 822 + } else if cfg!(windows) { 823 + // Звук компьютера в Windows разрешения не требует; микрофон — требует. 824 + "ms-settings:privacy-microphone" 825 + } else { 826 + return Err("На этой системе откройте настройки конфиденциальности вручную".into()); 827 + }; 828 + tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string()) 807 829 } 808 830 809 831 /// Открыть микрофон и звук компьютера без записи: интерфейс показывает две 827 849 let mut view = CheckView { 828 850 mic_error: None, 829 851 system_error: None, 852 + os: std::env::consts::OS, 830 853 }; 831 854 match meet_audio::open_microphone() { 832 855 Ok(capture) => tracks.push(Track { -
Показать разницуСкрыть разницу
85 85 commands::recorder_status, 86 86 commands::audio_check_start, 87 87 commands::audio_check_stop, 88 + commands::open_privacy, 88 89 commands::recorder_start, 89 90 commands::recorder_pause, 90 91 commands::recorder_stop, -
src/api.ts +3 −1
Показать разницуСкрыть разницу
88 88 | { kind: "heard"; data: { speaker: Speaker } }; 89 89 90 90 /** Проверка звука до записи: что не открылось и почему. */ 91 −export interface CheckView { mic_error: string | null; system_error: string | null } 91 +export interface CheckView { mic_error: string | null; system_error: string | null; os: string } 92 92 93 93 type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>; 94 94 type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>; 197 197 /** Открыть микрофон и звук компьютера без записи — только уровни (`meeting_id` событий пустой). */ 198 198 audioCheckStart: () => call<CheckView>("audio_check_start"), 199 199 audioCheckStop: () => call<void>("audio_check_stop"), 200 + /** Открыть настройки разрешений системы: «Запись экрана и системного звука» или «Микрофон». */ 201 + openPrivacy: (what: "screen" | "microphone") => call<void>("open_privacy", { what }), 200 202 recorderStart: () => call<Meeting>("recorder_start"), 201 203 recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }), 202 204 recorderStop: () => call<Meeting>("recorder_stop"), -
src/dev-mock.ts +10 −1
Показать разницуСкрыть разницу
116 116 (window as unknown as { __meetMock: object }).__meetMock = { 117 117 noKey: () => (key = null), 118 118 unsupported: () => (captureSupported = false), 119 + // Звук компьютера не открывается — как на Mac без разрешения. 120 + noSystem: () => (systemDenied = true), 119 121 }; 122 +let systemDenied = false; 120 123 121 124 export async function pickFolder(): Promise<string | null> { 122 125 return "/Users/demo/Documents/Записи встреч"; 298 301 emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs, ms: 0 } } }); 299 302 } 300 303 }, 200); 301 − return { mic_error: null, system_error: null } as T; 304 + return { 305 + mic_error: null, 306 + system_error: systemDenied ? "The user declined TCCs for application, window, display capture" : null, 307 + os: "macos", 308 + } as T; 302 309 } 310 + case "open_privacy": 311 + return undefined as T; 303 312 case "audio_check_stop": 304 313 window.clearInterval(checkTimer); 305 314 return undefined as T; -
src/recording.ts +43 −1
Показать разницуСкрыть разницу
19 19 "Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разрешите Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.", 20 20 }; 21 21 22 +const isMac = () => /Mac/i.test(navigator.userAgent); 23 + 24 +/** 25 + * Что делать, если звук компьютера или микрофон не пишется, — с кнопкой, 26 + * открывающей нужный раздел настроек системы. 27 + * 28 + * Главная ловушка macOS: разрешение запоминается за конкретной сборкой 29 + * программы. После обновления Skillok Meet в списке остаётся включённым, но 30 + * это разрешение прошлой версии — новой macOS прав не даёт. Лечится одним 31 + * способом: убрать Skillok Meet из списка и разрешить заново. 32 + */ 33 +export function permissionHelp(track: Track): string { 34 + if (track === "others") { 35 + if (!isMac()) return ""; 36 + return ` 37 + <div class="perm-help"> 38 + <p class="small">macOS не даёт записывать звук компьютера. Откройте «Запись экрана и системного звука» и проверьте Skillok Meet:</p> 39 + <ol class="small"> 40 + <li>Нет в списке или выключен — включите.</li> 41 + <li><b>Уже включён</b> — значит, разрешение осталось от прошлой версии программы, а новой macOS прав не даёт. Выделите Skillok Meet и удалите кнопкой «−».</li> 42 + <li>Закройте и снова откройте Skillok Meet, нажмите «Записать встречу» — macOS спросит разрешение, разрешите и ещё раз перезапустите программу.</li> 43 + </ol> 44 + <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="screen">Открыть настройки macOS</button> 45 + </div>`; 46 + } 47 + return ` 48 + <div class="perm-help"> 49 + <p class="small">Проверьте, что микрофон подключён и Skillok Meet разрешён доступ к нему.${ 50 + isMac() ? " После обновления программы разрешение тоже может слететь — выключите и включите его снова." : "" 51 + }</p> 52 + <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="microphone">Открыть настройки ${isMac() ? "macOS" : "Windows"}</button> 53 + </div>`; 54 +} 55 + 56 +/** Кнопки «Открыть настройки» внутри `root`. */ 57 +export function bindPermissionHelp(root: HTMLElement): void { 58 + root.querySelectorAll<HTMLButtonElement>("[data-open-privacy]").forEach((b) => 59 + b.addEventListener("click", () => api.openPrivacy(b.dataset.openPrivacy as "screen" | "microphone").catch(toast)), 60 + ); 61 +} 62 + 22 63 /** Слушать тишину дорожек на всю программу: предупреждение — и сообщением, и на панели. */ 23 64 export function startSilenceWatch(): void { 24 65 void events.recorder(({ meeting_id, event }) => { 121 162 ${source("others", "Звук компьютера")} 122 163 <span class="rec-size xs muted" data-size>${sizeText(pos, r.system ? 2 : 1)}</span> 123 164 </div> 124 − ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""} 165 + ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся.</p>${permissionHelp("others")}` : ""} 125 166 ${[...silent].map((t) => `<p class="rec-warn small" data-silent="${t}">${esc(SILENT_TEXT[t])}</p>`).join("")} 126 167 <div class="rec-actions"> 127 168 <button class="ui-btn" data-rec-stop>Остановить</button> 196 237 toast(err); 197 238 } 198 239 }); 240 + bindPermissionHelp(root); 199 241 root.querySelector("[data-rec-mark]")?.addEventListener("click", async () => { 200 242 const id = state.recording?.meeting_id; 201 243 if (!id) return; -
Показать разницуСкрыть разницу
183 183 .check-row.is-ok .check-state { color: var(--color-accent-text); font-weight: 700; } 184 184 .check-row.is-error .check-state, .check-row.is-error .check-hint { color: var(--color-error); } 185 185 186 +.perm-help { display: grid; gap: 8px; justify-items: start; padding: 10px 14px; border-left: 2px solid var(--color-error); background: var(--color-bg-secondary); color: var(--kv-body); } 187 +.perm-help ol { margin: 0; padding-left: 20px; display: grid; gap: 4px; } 188 +.check-row.is-error .check-hint .perm-help { color: var(--kv-body); } 189 +.perm-raw { word-break: break-word; } 190 + 186 191 /* ── Список записей на главной ── */ 187 192 .records { display: grid; gap: 12px; } 188 193 .records-head { display: flex; align-items: center; justify-content: space-between; } -
src/views/new.ts +9 −7
Показать разницуСкрыть разницу
9 9 import { api, events, type CheckView } from "../api"; 10 10 import { esc } from "../dom"; 11 11 import type { Scope } from "../scope"; 12 +import { bindPermissionHelp, permissionHelp } from "../recording"; 12 13 import { startRecording, state } from "../state"; 13 14 14 15 export async function renderNew(root: HTMLElement, scope: Scope): Promise<void> { 57 58 try { 58 59 check = await api.audioCheckStart(); 59 60 } catch (e) { 60 − check = { mic_error: String(e), system_error: String(e) }; 61 + check = { mic_error: String(e), system_error: String(e), os: "" }; 61 62 } 62 63 if (!scope.alive) { 63 64 void api.audioCheckStop(); 105 106 </div>`; 106 107 } 107 108 108 −/** Устройство не открылось — вместо полоски причина и где разрешить. */ 109 +/** Устройство не открылось — вместо полоски что делать и кнопка в настройки. */ 109 110 function showError(root: HTMLElement, key: "me" | "others", error: string | null): void { 110 111 if (!error) return; 111 112 const row = root.querySelector<HTMLElement>(`[data-check-row="${key}"]`); 113 114 row.dataset.error = "1"; 114 115 row.classList.add("is-error"); 115 116 row.querySelector("[data-check-state]")!.textContent = "не пишется"; 116 − const where = 117 − key === "others" 118 − ? " На Mac: Системные настройки → Конфиденциальность и безопасность → «Запись экрана и системного звука» → включите Skillok Meet и перезапустите программу." 119 − : " Проверьте, что микрофон подключён и программе разрешён доступ к нему."; 120 − row.querySelector("[data-check-hint]")!.textContent = error + where; 117 + const help = permissionHelp(key); 118 + const hint = row.querySelector<HTMLElement>("[data-check-hint]")!; 119 + hint.innerHTML = help || esc(error); 120 + // Текст ошибки системы — мелко, для того, кто будет разбираться. 121 + if (help) hint.insertAdjacentHTML("beforeend", `<p class="xs muted perm-raw">${esc(error)}</p>`); 122 + bindPermissionHelp(row); 121 123 }