skillok meeting

Разрешения: шаги и кнопка «Открыть настройки» вместо системной ошибки

На Mac разрешение «Запись экрана и системного звука» запоминается за конкретной сборкой программы (сборки подписаны ad-hoc): после обновления Skillok Meet в списке включён, а новой версии macOS прав не даёт. - open_privacy: открывает нужный раздел настроек — «Запись экрана и системного звука» или «Микрофон» на macOS, «Микрофон» в Windows. - Проверка звука и панель записи: вместо текста ошибки — что делать, с объяснением ловушки «включено, но от прошлой версии — удалите «−» и разрешите заново», и кнопка в настройки; текст системы — мелко ниже. - CheckView.os. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 4 октября 2026 г. ade9583c661cc1280b544b11b9e57c1c48bba461
8 файлов
  • 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 }