Вступление
Всем доброго дня! В предыдущей статье Kawai-Focus 2.6: путь к MVP1 — создание экрана Таймер:
- Написана основа для экрана «Таймер»;
- Добавлен прогресс-бар.
В предыдущей статье я написал основу для экрана «Таймер», которая уже способна воспроизводить таймер. Однако была завершена лишь часть работы над данным экраном. Для полноценной работы необходимо реализовать воспроизведение звука, который будет оповещать пользователя, когда закончится время.
Также для реализации полноценной Pomodoro-системы необходимо реализовать механизм воспроизведения цепочки таймеров друг за другом.
Заваривайте чай, доставайте вкусняшки — пора «научить группу помидоров звучать»! 🍅
Воспроизведение звука
Пожалуй, одной из важнейших функций таймера является воспроизведение звука. Ведь беззвучно завершившийся таймер человек, скорее всего, заметит не сразу и потеряет время, которое мог бы потратить на работу или хобби. Как правило, пользователь сворачивает окно таймера, поэтому звуковое оповещение жизненно важно для своевременного уведомления о завершении времени.
В приложении на Tauri, написанном с использованием Rust, TypeScript и Vue, существует несколько способов воспроизведения звука. Например, на стороне Rust можно использовать библиотеку rodio, которая позволяет воспроизводить аудио напрямую через backend приложения. На стороне TypeScript и Vue можно использовать стандартный объект Audio для запуска звуковых файлов через frontend. Также существует вариант воспроизведения звука средствами браузера через Web Audio API, который предоставляет более гибкие возможности для работы со звуком, например управление громкостью, эффектами и генерацией аудио в реальном времени.
В данном проекте я решил использовать Web Audio API. Этот вариант хорошо подходит для приложений на Tauri, так как работает через web-технологии и не требует написания отдельной аудиологики для каждой операционной системы. Благодаря этому механизм воспроизведения звука будет одинаково работать на Linux, Windows и macOS, что особенно удобно при кроссплатформенной разработке.
При этом вся логика может быть реализована на TypeScript и Vue, без необходимости подключать дополнительные Rust-библиотеки для работы с аудио. Это упрощает разработку, уменьшает количество платформозависимого кода и делает поддержку проекта более удобной.
Выбор аудио для таймера
Перед тем как писать код для воспроизведения звука, было бы неплохо подготовить сами аудиофайлы. Для этого необходимо ответить на несколько вопросов:
- Какие форматы аудио будут поддерживаться?
- Откуда брать сами аудиофайлы и какими они будут?
- Где будут храниться аудиофайлы?
- Где будут храниться настройки аудио?
- Как пользователь будет настраивать аудио?
Web Audio API поддерживает большинство популярных аудиоформатов, которые работают в браузерных движках Chromium и WebKit, используемых в Tauri 2.0. В качестве основного формата я планирую использовать mp3, так как он хорошо поддерживается на Linux, Windows и macOS, а также имеет небольшой размер файлов. Дополнительно можно поддерживать ogg и wav. Формат wav удобен для коротких звуков без сжатия, однако его файлы занимают значительно больше места.
В качестве стандартных звуков для Pomodoro будут использоваться несколько готовых аудиофайлов, скачанных из открытых источников. Например, для этого подойдут сайты с бесплатными звуками и лицензиями Creative Commons: Freesound, Pixabay или Mixkit. При этом пользователь также сможет выбрать собственный аудиофайл для таймера, чтобы настроить приложение под себя.
Стандартные аудиофайлы будут храниться во frontend-части проекта, например в папке src/assets/audio. Так как воспроизведение звука будет реализовано через Web Audio API на TypeScript, хранение аудио рядом с frontend-кодом является наиболее простым и удобным вариантом.
Для уменьшения размера приложения аудио можно предварительно сжать, например в mp3 с битрейтом около 256 kbps. Для коротких звуков этого качества более чем достаточно. Пользовательские аудиофайлы не будут копироваться внутрь приложения. Вместо этого будет храниться путь к файлу в файловой системе пользователя. Это позволит не дублировать файлы и избежать лишнего расхода места на диске.
Настройки аудио удобнее всего хранить в конфигурационном файле приложения. Для Tauri хорошим вариантом является хранение настроек в формате TOML, так как он простой, читаемый и уже активно используется в Rust-проектах. В конфиге можно хранить громкость, путь к пользовательскому аудио и выбранный стандартный звук.
В будущем для настройки звуков будет реализован отдельный экран настроек. На нём пользователь сможет выбрать стандартный звук, указать собственный аудиофайл, изменить громкость и протестировать воспроизведение прямо внутри приложения.
Работа с конфигурацией звука: загружаем и сохраняем настройки
Разобравшись с форматами и источниками аудио, перехожу к не менее важной части — где и как хранить пользовательские настройки. Мне нужно запоминать, какой звук выбран. Для Tauri‑приложений на Rust самым естественным форматом конфигурационных файлов является TOML — он прост, читаем и отлично интегрируется с экосистемой Rust. Хранить такой файл лучше в директории конфигурации приложения, к которой я получаю доступ через плагин fs.
Подключаю доступ к файловой системе
Первым делом добавляю в Cargo.toml плагин для работы с файловой системой:
cargo add tauri-plugin-fsНа фронтенде устанавливаем одноимённый npm‑пакет и библиотеку для парсинга TOML:
npm install @tauri-apps/plugin-fsnpm install tomlТеперь приложение может читать и писать файлы в защищённые директории приложения.
Структура конфигурационного файла
В папке public (статический ресурс фронтенда) создаю шаблонный файл config.toml. Он будет использоваться как дефолтный, если пользовательского конфига ещё нет:
# Конфиг таймера
[sound]
sound_id = "alarm_beep"Здесь у меня пока только один параметр — идентификатор выбранного звука. В будущем сюда же добавятся volume, custom_audio_path и другие поля.
Типизация конфига
Чтобы TypeScript понимал структуру данных, объявлю простой тип в types/configType.ts:
//** Тип для конфига */
export type Config = {
sound: {
sound_id: string;
}
}Согласитесь, это значительно удобнее, чем работать с сырыми объектами.
Логика загрузки конфига: хитрости с fallback
Самое интересное происходит в composables/useConfig.ts. Здесь реализована функция loadConfig, которая загружает или создаёт конфиг. Разберём её по шагам.
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import TOML from 'toml';
import { Config } from '@/types/configType';
/**
* Загрузит или создаст TOML-конфиг
*/
export async function loadConfig(): Promise<Config> {
let text: string |null = null;
const configName = 'config.toml';
// 1. пробуем прочитать пользовательский конфиг
try {
text = await readTextFile(configName, {
baseDir: BaseDirectory.AppConfig
});
} catch {
text = null;
}
// 2. если нет — создаст из дефолта
if (!text) {
const defaultConfig = await fetch(`/${configName}`)
.then(r => r.text());
await writeTextFile(configName, defaultConfig, {
baseDir: BaseDirectory.AppConfig
});
text = defaultConfig;
}
// 3. парсим TOML
return TOML.parse(text) as Config;
}Как это работает?
- Чтение существующего конфига
readTextFileс параметромbaseDir: BaseDirectory.AppConfigобращается к директории конфигурации приложения (на разных ОС это свои пути:~/.config/название_приложенияна Linux,AppData\Roamingна Windows,~/Library/Application Supportна macOS). Если файл существует — получаем его содержимое в виде строки. - Fallback на дефолтный конфиг
Если при чтении произошла ошибка (файла нет или он повреждён), мы загружаемconfig.tomlиз папкиpublicчерез обычныйfetch. Это возможно, потому что при сборке Tauri статические файлы изpublicкопируются в корень конечного приложения. Затем мы записываем этот дефолтный конфиг в ту жеAppConfigдиректорию. Таким образом, при первом запуске приложения файл конфигурации создаётся автоматически. - Парсинг и возврат
ФункцияTOML.parseпревращает строку в объект, который мы приводим к типуConfig. Теперь в любом месте приложения можно вызватьawait loadConfig()и получить готовые настройки звука с гарантией, что они существуют.
Далее я покажу, как на основе загруженного конфига воспроизводить выбранный звук через Web Audio API.
Воспроизведение звука: от конфига до динамиков
Теперь, есть конфигурационный файл, указывающий выбранный звук, самое время заставить таймер не просто показывать уведомление, а реально издавать звуковой сигнал. В предыдущей части мы подготовили аудиофайлы и научились читать настройки. Здесь мы реализуем саму звуковую подсистему на основе Web Audio API — современного, гибкого и низкоуровневого интерфейса для работы со звуком в браузере.
Библиотека звуков: где живут файлы
Сначала определяю, где хранятся стандартные аудиофайлы во frontend-части. Для этого создам модуль composables/libAudio.ts. Его задача — сопоставить символьный идентификатор звука (sound_id из конфига) с реальным путём к MP3-файлу.
/** Получает путь к аудио по id */
export function getSoundPathById(id: string): string {
const sound = SOUND_LIBRARY.find(s => s.id = id);
if (!sound) {
throw new Error("Sound not found");
}
return sound.file;
}
const base_path = "/sounds";
const SOUND_LIBRARY = [
{ id: "alarm_beep", file: `${base_path}/alarm-beep.mp3`, name: "Alarm Beep" }
];Путь начинается с /sounds. Это означает, что файлы будут доступны из корня веб-приложения. Я помещу их в папку public/sounds при сборке, чтобы Tauri мог отдавать их как статические ресурсы. Такой подход избавляет от необходимости работать с файловой системой ОС для встроенных звуков — достаточно обычного fetch. В будущем массив SOUND_LIBRARY легко расширить новыми предустановленными звуками.
Модуль воспроизведения: useAudio.ts
Основная логика работы со звуком вынесена в useAudio.ts. Здесь я буду использовать глобальный экземпляр AudioContext и управлять текущим источником звука. Web Audio API требует аккуратной работы с жизненным циклом узлов, чтобы избежать утечек памяти и наложений звуков.
Глобальные переменные
let audioContext: AudioContext | null = null;
let currentSource: AudioBufferSourceNode | null = null;audioContext— контекст, через который проходит весь аудиосигнал. Он создаётся один раз при первом воспроизведении.currentSource— активный в данный момент источник (буфер), нужен, чтобы иметь возможность прервать воспроизведение.
Инициализация контекста
Функция getAudioContext реализует ленивую инициализацию:
/** Получить AudioContext */
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
}Почему именно так?
AudioContext может быть создан только после какого-либо жеста пользователя на странице (современные браузеры блокируют автоматическое воспроизведение). Поэтому я не создаю его сразу при загрузке приложения, а дожидаюсь момента, когда таймер действительно должен прозвенеть. Пользователь уже нажал «Старт» — значит, жест был, и браузер разрешит AudioContext.
Функция воспроизведения
Сердце модуля — playSound, принимающая URL аудиофайла:
/** Воспроизводит звук */
export async function playSound(url: string): Promise<void> {
const context = getAudioContext();
if (context.state = 'suspended') {
await context.resume();
}
// Если уже играет звук — остановит
stopSound();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await context.decodeAudioData(arrayBuffer);
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
currentSource = source;
source.start(0);
source.onended = () => {
source.disconnect();
if (currentSource = source) {
currentSource = null;
}
};
}Разберём по шагам:
- Активация контекста — если он был приостановлен браузером, вызываем
resume(). - Остановка предыдущего звука — вызываем вспомогательную функцию
stopSound(), чтобы не было наложения (например, если таймер сработал несколько раз подряд). - Загрузка и декодирование —
fetchзагружает MP3 как бинарные данные,decodeAudioDataпревращает их во внутреннее представлениеAudioBuffer. - Создание источника —
createBufferSource()генерирует узел, который будет воспроизводить буфер. - Подключение к выходу — соединяем источник с
context.destination(динамики/наушники). - Запуск —
start(0)начинает воспроизведение немедленно. - Очистка после окончания — вешаем обработчик
onended, который отключает источник от графа и обнуляет глобальную ссылку, если это был именно тот источник.
Остановка воспроизведения
Функция stopSound пригодится не только при смене звуков, но и если пользователь захочет прервать сигнал раньше времени:
/** Останавливает звук */
export function stopSound(): void {
if (currentSource) {
currentSource.stop();
currentSource.disconnect();
currentSource = null;
}
}Важно: stop() нельзя вызвать повторно для одного и того же источника — это выбросит исключение. Поэтому я проверяю существование currentSource и обнуляю его сразу после вызова.
В основном компоненте таймера вместо заглушки alert можно будет
- Загрузить конфиг через
loadConfig(). - Получить идентификатор выбранного звука (
sound_id). - Преобразовать его в путь через
getSoundPathById(). - Вызвать
playSound()с этим путём.
Цепочка таймеров
Цепочка таймеров состоит из «помидоров», коротких перерывов и одного длительного перерыва.
Пример настройки цепочки таймеров:
- Помидор — 55 минут;
- Короткий перерыв — 10 минут;
- Длительный перерыв — 15 минут;
- Количество помидоров — 4 шт.
Пример цепочки:
- Помидор → короткий перерыв → помидор → короткий перерыв → помидор → короткий перерыв → помидор → длительный перерыв.
После нажатия кнопки «Стоп» система будет переключать пользователя на следующий таймер цепочки. Это можно будет сделать как во время воспроизведения таймера, так и во время паузы или звучания сигнала завершения таймера.
Как реализовать цепочку в коде?
- Происходит CRUD-операция к базе данных SQLite3, которая получает данные цепочки;
- На основе полученных данных формируется список со звеньями цепочки;
- В порядке очереди из списка берётся каждый таймер звена и воспроизводится.
Мне понадобятся типы для описания звеньев цепочки и функция, которая формирует очередь таймеров на основе параметров, загруженных из базы данных.
Типизация: что такое звено таймера
В файле types/timerType.ts мы описываем структуру данных, которыми будет оперировать chain-механизм.
/** Параметры инициализации обратного отсчёта */
export type CountdownOptions = {
timers: TypeTimer[];
onFinish?: () => void;
}
/** Тип таймеров */
export type TypeTimer = {
title: string;
time: number;
typeTimer: string;
}Обратите внимание на поле timers: TypeTimer[] внутри CountdownOptions. Это массив звеньев, которые нужно воспроизвести последовательно. По сути, это и есть цепочка. Раньше был один таймер с фиксированным временем, теперь передаётся список: сначала один TypeTimer, потом другой, третий и так далее. Компонент обратного отсчёта будет просто брать следующий таймер из этого массива, когда предыдущий завершится (или его прервут кнопкой «Стоп»).
Сам TypeTimer — это минимальное описание одного звена:
title— название таймера. Может быть общим для всей цепочки (например, «Изучение TypeScript»), а может различаться. В нашем случае используется общий заголовок из настроек.time— длительность в секундах (или минутах — зависит от имплементации, но по коду видно, что в базе хранятся минуты). Это рабочее время, время короткого перерыва или длинного перерыва.typeTimer— строковой идентификатор типа: «Помидор», «Перерыв», «Перерывище» (забавное название для длинного перерыва). Нужен для отображения пользователю и, возможно, для разной логики (например, звук завершения помидора может отличаться от звука завершения перерыва).
Такая структура легко расширяется: позже можно добавить поле soundId для переопределения звука на конкретном звене, или autoStart для автоматического перехода.
Формирование очереди: из одной записи БД в цепочку таймеров
Самый интересный код находится в composables/chainTimer.ts. Здесь происходит чтение настройки цепочки из SQLite (одна строка таблицы timers) и генерирование плоского списока TypeTimer[], который потом будет "скормлен" компоненту обратного отсчёта.
import { TypeTimer } from '@/types/timerType';
import { getTimer } from '@/db/crud/timerCrud';
/** Фомирует цепочку таймеров */
export async function queueTimer(TimerId: number): Promise<TypeTimer[]> {
let timers: TypeTimer[] = [];
const result = await getTimer(TimerId);
const timerData = Array.isArray(result) ? result[0] : result;
for (let i = 0; i < timerData.count_pomodoro; i++) {
timers.push({
title: timerData.title,
time: timerData.pomodoro_time,
typeTimer: "Помидор"
});
if (i = timerData.count_pomodoro - 1) {
timers.push({
title: timerData.title,
time: timerData.break_long_time,
typeTimer: "Перерывище"
});
} else {
timers.push({
title: timerData.title,
time: timerData.break_time,
typeTimer: "Перерыв"
});
}
}
return timers;
}Разберём логику по шагам:
Получение данных цепочки
getTimer(TimerId)обращается к SQLite и возвращает объект с полями:count_pomodoro— сколько помидоров в цикле (обычно 4).pomodoro_time— длительность одного помидора.break_time— длительность короткого перерыва.break_long_time— длительность длинного перерыва.title— название задачи/проекта.
Строчка
Array.isArray(result) ? result[0] : result— небольшая страховка на случай, еслиgetTimerвозвращает массив (например, от SQLite-обёртки), и мы берём первый элемент.- Формирование последовательности
Циклfor (let i = 0; i < timerData.count_pomodoro; i++)создаёт пары «помидор → перерыв». На каждой итерации сначала добавляется помидор:
timers.push({
title: timerData.title,
time: timerData.pomodoro_time,
typeTimer: "Помидор"
});А затем — перерыв. Но есть важное условие: если итерация последняя (`i = timerData.count_pomodoro - 1`), то вместо короткого перерыва добавляется _длинный перерыв_ (Перерывище). В противном случае — обычный короткий перерыв.
Благодаря такому подходу для 4 помидоров получаю цепочку:
- Помидор → Перерыв → Помидор → Перерыв → Помидор → Перерыв → Помидор → ДлинныйПерерыв.
Итого 8 звеньев. Если пользователь остановит цепочку на полпути, при следующем запуске (или нажатии «Стоп», чтобы переключиться вручную) просто продолжает идти по этому списку с текущего индекса.- Возврат очереди
Функция возвращаетTypeTimer[], который полностью готов к передаче вCountdownOptions.
Как это связано с пользовательским интерфейсом?
Теперь в главном компоненте таймера при старте делаю:
const chain = await queueTimer(activeTimerId);
startCountdown({
timers: chain,
onFinish: () => {
// Цепочка полностью завершена
showNotification("Молодец! Цикл помидоров окончен");
}
});Кнопка «Стоп» будет не просто останавливать текущий таймер, а переключать на следующий элемент массива timers. Если текущий индекс меньше длины массива — берётся следующий и запускается. Если достигнут конец — цепочка пройдена.
Интеграция цепочки таймеров в компонент: от теории к реальному коду
В предыдущем разделе формировалась очередь таймеров из настроек БД. Теперь самое главное — «скрестить» эту очередь с реальным UI, который уже был. Добавлю поддержку последовательных этапов (помидор → перерыв → помидор → …), звукового сигнала при завершении этапа, а также правильное поведение кнопок «Старт», «Пауза» и «Стоп».
Новый композабл для управления цепочкой
Расширю логику в composables/useTimer.ts — именно здесь будет жить вся логика обратного отсчёта с поддержкой списка таймеров. Классический подход с одним таймером больше не подходит: мне нужно по очереди извлекать звенья из массива timers, следить за текущим, переключаться на следующий и сигнализировать о завершении всего цикла.
Весь код хорошо прокомментирован, я лишь выделю ключевые нововведения по сравнению с одиночным таймером.
export function useCountdown({ timers, onFinish }: CountdownOptions): CountdownReturn {
const totalSeconds = ref<number>(0);
const timerNow = ref<TypeTimer | undefined>(timers.shift());
if (timerNow.value) {
totalSeconds.value = timerNow.value.time * 60;
}
// ...
}timers.shift()— при инициализации сразу забирает первый элемент очереди. Это одновременно и получение текущего звена, и удаление его из массива. Такой подход упрощает логику: когда таймер завершается (или пользователь нажимает «Стоп»), мы просто вызываемsetNextTimer(), которая снова сделаетshift()и установит новыйtimerNow.totalSeconds— полная длительность текущего таймера в секундах (хранится в минутах, умножаем на 60). Нужна для вычисления прогресса.
const setNextTimer = (): void => {
timerNow.value = timers.shift();
if (timerNow.value) {
totalSeconds.value = timerNow.value.time * 60;
timeLeft.value = totalSeconds.value;
}
};Важно: если в очереди больше нет элементов, timerNow становится undefined. Это сигнал о том, что вся цепочка пройдена. В шаблоне покажется надпись «Завершён!» и произойдёт сокрытие прогресс-бар.
const finish = (): void => {
// ... очистка интервала, сброс флагов
timeLeft.value = 0;
onFinish?.(); // вызываем колбэк, переданный из компонента
};Здесь важно: onFinish вызывается каждый раз, когда завершается текущий таймер (помидор или перерыв). В компоненте в этом колбэке проигрывается звук — и затем автоматически переход к следующему звену? Нет! Если взглянуть внимательнее: finish не вызывает setNextTimer(). Почему? Потому что переход на следующий таймер должен происходить либо автоматически по истечении времени, либо по нажатию «Стоп».
В моей реализации после вызова finish таймер останавливается, и пользователь должен сам нажать «Старт» для следующего этапа. Это сделано сознательно: техника Pomodoro предполагает, что после звонка пользователь сам решает, когда начать следующий интервал. Однако если нужен автоматический переход — можно модифицировать finish, добавив вызов setNextTimer() и запуск, но в данном коде выбрано ручное управление.
const stop = (): void => {
// ... остановка интервала
stopSound(); // прерывает возможное звучание сигнала
setNextTimer(); // переключает на следующий таймер
};Кнопка «Стоп» выполняет две важные функции: останавливает текущий звук (если он играет) и принудительно переключает на следующий этап цепочки. Это соответствует требованию из технического задания: «система будет переключать пользователя на следующий таймер цепочки» при нажатии «Стоп». А если пользователь хочет просто передохнуть в середине помидора — он нажмёт «Пауза», которая не трогает очередь.
Интеграция в компонент Timer
В основном компоненте Timer/Timer.ts (или .vue) теперь нужно:
- Загрузить конфигурацию звука.
- Получить цепочку таймеров через
queueTimer. - Создать экземпляр
useCountdown, передав массивtimersи колбэкonFinishс воспроизведением звука. - Обрабатывать кнопки UI, вызывая методы
start,pause,stopиз композабла.
Добавлю новые импорты:
import { playSound } from '@/composables/useAudio';
import { getSoundPathById } from '@/composables/libAudio';
import { loadConfig } from '@/composables/useConfig';
import { queueTimer } from '@/composables/chainTimer';Загружается конфиг (один раз, но можно и реактивно). Внимание: loadConfig возвращает Promise, поэтому при инициализации использует await или затем. В примере сохраняют config в переменную, но затем обращаются к нему через (await config) — это немного неоптимально. Лучше дождаться загрузки до создания таймера. Вот так:
const config = await loadConfig(); // загружаем настройки звука
const timers = await queueTimer(activeTimerId); // формируем цепочку
countdown.value = useCountdown({
timers: timers,
onFinish: async () => {
// по завершении каждого звена проигрываем выбранный звук
await playSound(getSoundPathById(config.sound.sound_id));
}
});Теперь при каждом завершении интервала (будь то помидор или перерыв) будет играть один и тот же звук (например, «alarm_beep»). При этом сам таймер останавливается, а на экране появляется кнопка «Старт» для следующего этапа.
Изменения в шаблоне (Timer.html)
Чтобы пользователь понимал, на каком этапе он находится, нужно отображать название и тип текущего таймера. Раньше был просто фиксированный заголовок. Теперь использутся поля title и typeTimer из timerNow.value:
<span class="label-left">{{ countdown?.timerNow.value?.title }}</span>
<span class="label-right">{{ countdown?.timerNow.value?.typeTimer }}</span>label-left— например, название задачи (берётся из БД).label-right— что сейчас: «Помидор», «Перерыв» или «Перерывище».
Прогресс-бар имеет смысл показывать только пока цепочка не завершена. Добавлю условие:
<div v-show="!countdown?.isFinished.value" class="timer-progress-bar">isFinished — вычисляемое свойство из useCountdown, оно возвращает true, если timerNow отсутствует или оставшееся время <= 0. После полного прохождения всех таймеров прогресс-бар скрывается.
Кнопки тоже должны менять видимость в зависимости от состояния таймера и завершённости цепочки.
Кнопка «Старт» должна показываться в двух случаях:
- таймер в состоянии ожидания (
state = 'idle') и цепочка ещё не завершена; - таймер на паузе (
state = 'paused').
v-show="state = 'idle' && !countdown?.isFinished.value || state = 'paused'"Кнопка «Пауза» — только когда таймер работает и цепочка не закончена:
v-show="state = 'running' && !countdown?.isFinished.value"А кнопка «Стоп» остаётся видимой всегда, кроме состояния завершения (или же скрывается, когда isFinished). Её поведение уже описано — она вызывает countdown.stop(), который переключает на следующий таймер и останавливает звук.
Как теперь выглядит полный цикл работы
- Пользователь выбирает настройки таймера (4 помидора, перерывы 5 и 15 минут).
- Приложение загружает конфиг звука и формирует очередь из звеньев.
- На экране — первый помидор с таймером 25 минут (или сколько задано). Пользователь жмёт «Старт».
- По истечении времени срабатывает
onFinish→ проигрывается звук. Таймер останавливается, показывает завершённый этап. - Пользователь видит кнопку «Старт» (теперь для короткого перерыва). Нажимает — идёт перерыв.
- И так по кругу. В любой момент можно нажать «Стоп» — звук прекратится, и сразу начнётся следующий этап (например, пропустить остаток перерыва).
- После последнего длинного перерыва
timerNowстановитсяundefined,isFinished=true, прогресс-бар и кнопки скрываются, пользователь видит сообщение «Завершён!».
Таким образом, я полностью реализовал поддержку цепочки таймеров, сохранив гибкость управления и полностью интегрировав звуковую подсистему. Всё это без лишней сложности благодаря продуманному композаблу useCountdown и аккуратным изменениям в шаблоне.
Проверка работы приложения и подводные камни Tauri 2.0
Настал долгожданный момент — функционал написан, архитектурные наброски сделаны, и пришло время проверить наше приложение в деле. По старой доброй традиции, запускаю проверку в режиме разработчика (dev-режиме):
cargo tauri devОбычно на этом этапе ожидаешь увидеть привычное окно инициализации, но Tauri второй версии часто преподносит сюрпризы, особенно если проект до этого активно обновлялся или переносился с прошлых альфа/бета-версий. Вместо запуска я натолкнулся на каскад ошибок.
Поскольку в процессе разбора пришлось попотеть, я решил подробно задокументировать этот опыт прямо в статье. Уверен, эта шпаргалка сэкономит кучу времени тем, кто сейчас активно переводит свои проекты на рельсы Tauri 2.0.
Ошибка #1: Фантомные файлы и конфликт артефактов (failed to read plugin permissions)
Первый удар прилетел еще на этапе компиляции Rust-бэкенда. Сборщик споткнулся на полуслове и выдал в терминал следующее полотно:
failed to read plugin permissions:
...
commands/app_hide.toml': No such file or directoryВ чем корень проблемы?
Tauri 2.0 под капотом использует сложную систему генерации кода для управления правами доступа (Permissions). Когда подключаются или обновляются плагины, макросы Tauri генерируют конфигурационные файлы разрешений «на лету» внутри директории target/.
Если в процессе разработки обновился сам Tauri, изменилась версия билдера (tauri-build), или разработчик удалил/переименоваи какую-то команду в коде (как в моем случае произошло с app_hide), кэш компилятора Rust остаётся в рассинхронизации. Сборщик пытается прочитать автосгенерированный .toml файл плагина по старому пути, но физически его там уже нет.
Пошаговое лечение и тотальная очистка окружения
В таких ситуациях пытаться исправлять что-то точечно — пустая трата времени. Лучше всего полностью сбросить состояние окружения и компилятора. Для этого я последовательно применил три команды.
Шаг 1. Мягкая очистка через Cargo
cargo cleanКак это работает?
Это штатная команда экосистемы Rust. Она полностью вычищает директории target/debug и target/release, удаляет промежуточные build-файлы, макросы и сгенерированный кэш плагинов Tauri. Проект подготавливается к честной компиляции с нуля.
Шаг 2. Принудительное удаление (Радикальный метод)
rm -rf target Как это работает?
Иногда cargo clean не справляется. Из-за блокировок файлов операционной системой или специфических сбоев внутри файловой структуры компилятора в папке target остаются «битые» скрытые файлы. Флаг -r (recursive) указывает удалить папку со всем содержимым, а -f (force) отключает любые запросы на подтверждение. Это гарантирует, что старый кэш Tauri стерт физически.
Шаг 3. Сброс оптимизаций фронтенда
rm -rf ../node_modules/.viteКак это работает?
Если ваш фронтенд собран на Vite (что для Tauri сейчас является стандартом де-факто), у него есть собственный внутренний кэш. Vite предварительно собирает зависимости (prebundled dependencies) и кэширует модули для ускорения HMR (Hot Module Replacement). При конфликтах версий бэкенда старый кэш Vite может вызывать фантомные ошибки импортов или алиасов. Эта команда заставляет Vite пересобрать весь фронтенд-слой заново.
После проведения этой генеральной уборки проект компилируется чисто, и Rust-часть успешно заводится. Но радоваться было рано — проблемы переместились на сторону фронтенда.
Ошибка #2: Новая парадигма безопасности (Unhandled Promise Rejection: fs.read_text_file not allowed)
После успешной компиляции открылось заветное окно приложения. Однако интерфейс остался девственно пустым. Открыв DevTools (инструменты разработчика), в консоли я обнаружил суровый отказ в доступе:
Unhandled Promise Rejection: fs.read_text_file not allowed. Permissions associated with this command: fs:allow-app-read, fs:allow-app-read-recursive, fs:allow-appcache-read, fs:allow-appcache-read-recursive, fs:allow-appconfig-re...В чем корень проблемы?
Главное архитектурное отличие Tauri 2.0 от первой версии — внедрение Capability-based permissions (безопасность на основе возможностей и явных разрешений) и ACL (Access Control Lists).
В первой версии Tauri мы могли просто включить плагин fs в tauri.conf.json и читать любые файлы. В Tauri 2.0 запрещено всё по умолчанию. Если ваш JavaScript/TypeScript код пытается вызвать метод fs.read_text_file(), ядро Tauri жестко блокирует этот вызов на системном уровне, так как у фронтенда нет «документов» на выполнение этой операции. Приложение пыталось прочитать конфигурационный файл, но получило по рукам от системы безопасности.
Как это исправить?
Чтобы исправить это, необходимо явно выдать приложению права на чтение и запись, а также строго ограничить область его видимости (scope), чтобы оно не могло гулять по всей операционной системе пользователя.
Перехожу в файл конфигурации разрешений по пути src-src/capabilities/default.json и приводим его к следующему виду:
"permissions": [
"core:default",
"sql:default",
"sql:allow-execute",
"fs:allow-appconfig-read",
"fs:allow-appconfig-write"
],
"fs": {
"read": {
"scope": [
"$APPCONFIG/**"
]
},
"write": {
"scope": [
"$APPCONFIG/**"
]
}
}Разбор полётов: что я изменил в конфигурации?
- В массив
"permissions"я добавил две строгие директивы:fs:allow-appconfig-read— эта возможность открывает фронтенду легальный доступ к системным командам чтения файлов, сгенерированных плагином FS.fs:allow-appconfig-write— аналогично, даёт зелёный свет на запись и обновление конфигурационных файлов из веб-интерфейса.
- Объект
"fs"и массив"scope"— это наша зона безопасности. Переменная$APPCONFIG/**указывает Tauri, что область работы плагина жестко ограничена стандартной системной папкой конфигурации вашего приложения (например,~/.config/your-app-nameв Linux илиAppData/Roamingв Windows). Знак**разрешает рекурсивный доступ к любым вложенным папкам и файлам внутри этого пути.
Ошибка #3: Забытый мост между мирами (plugin fs not found)
Казалось бы, права настроены, безопасность довольна. Перезапускаю приложение, но в консоли DevTools появляется новая ошибка, которая окончательно сбивает с толку:
[Error] Unhandled Promise Rejection: plugin fs not found loadConfig (useConfig.ts:4)Эта ошибка возникла в строке инициализации кастомного хука (в файле useConfig.ts), который отвечает за загрузку конфигурации при старте интерфейса.
В чем корень проблемы?
Я дал фронтенду разрешение (Capability) использовать плагин fs, но забыл сделать самое главное — физически подключить и скомпилировать сам плагин внутри Rust-части нашего приложения.
В Tauri 2.0 конфигурация прав в JSON и инициализация кода в Rust работают в тесной связке. Если плагин не зарегистрирован в рантайме ядра, то для TypeScript-окружения его просто не существует — мост (IPC, Inter-Process Communication) между веб-страницей и операционной системой не построен.
Как это исправить?
Мне нужно зайти в бэкенд-часть проекта и инициализировать плагин файловой системы в конструкторе приложения.
В файл src-tauri/src/lib.rs и добавляю плагин в цепочку билдера:
tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("Ошибка при запуске приложения Tauri.");Что делает строка .plugin(tauri_plugin_fs::init())?
Эта команда выполняет сразу несколько важнейших задач на стороне Rust:
- Регистрирует внутренние IPC-обработчики (команды) плагина файловой системы в рантайме Tauri.
- Связывает эти команды с системой проверки прав доступа (ACL), которую мы настроили на предыдущем шаге в
default.json. - Пробрасывает системные вызовы операционной системы для чтения, записи, удаления и создания файлов/директорий, делая их доступными для вызова через JavaScript API.
Демо ролик
На этот раз я записал видео демонстрации работы приложения вместо сриншотов.
Приятного просмотра.
Demo Кавай-Фокус таймер pomodoro
Анонс на следующие статьи
В следующей статье мне предстоит реализовать экран конструктора таймеров, в котором пользователь сможет создавать собственные кастомные цепочки Pomodoro.
Планирую добавить создание:
- помидоров;
- коротких перерывов;
- длительных перерывов;
- количества повторений Pomodoro-сессий.
Если у вас есть мысли о том, как можно улучшить проект, пишите в комментариях — с удовольствием ознакомлюсь с вашими предложениями!
Читайте продолжение — не пропустите!
Ссылки к статье
- Мои статьи Arduinum628 на Код на салфетке;
- Репозиторий проекта на Github kawai-focus-v2.
- Репозиторий проекта на Gitverse kawai-focus-v2
Комментарии