19 Commits

Author SHA1 Message Date
Mikhail Kiselev
41c3fd4934 feat: save and restore window position 2026-02-10 23:36:06 +03:00
Mikhail Kiselev
6783ed8b0a docs: update version to v0.2.2. add click-to-switch feature 2026-02-10 23:16:13 +03:00
Mikhail Kiselev
fe6a41226c fix: layout, hotkeys, skip/switch speaker logic 2026-02-10 23:10:02 +03:00
Mikhail Kiselev
fc93ebbd26 fix: add async/await to timer event handlers for sound 2026-02-10 18:23:37 +03:00
Mikhail Kiselev
f0a8c32ea2 feat: show spent time in participant list, fix timer sounds 2026-02-10 18:15:18 +03:00
Mikhail Kiselev
850d1deed2 fix: remove outline from nav buttons 2026-02-10 18:03:48 +03:00
Mikhail Kiselev
5131a72983 fix: warm up AudioContext on first click, clean up code 2026-02-10 17:54:31 +03:00
Mikhail Kiselev
6dac14e0c1 fix: add delay after AudioContext resume and schedule offset 2026-02-10 17:48:34 +03:00
Mikhail Kiselev
482786a34b fix: await first playBeep in sound sequences 2026-02-10 16:53:40 +03:00
Mikhail Kiselev
906f504d49 fix: await AudioContext.resume() before playing 2026-02-10 16:45:56 +03:00
Mikhail Kiselev
649b1c039d chore: update wails bindings 2026-02-10 16:27:17 +03:00
Mikhail Kiselev
5fd85bfc50 fix: add AudioContext resume in main timer 2026-02-10 16:23:30 +03:00
Mikhail Kiselev
809f64b93d feat: add custom sound upload and fix localization 2026-02-10 16:19:39 +03:00
Mikhail Kiselev
30af8729b8 docs: add auto-update documentation 2026-02-10 16:03:39 +03:00
Mikhail Kiselev
cf0d60f40c fix: improve app restart after update 2026-02-10 16:00:06 +03:00
Mikhail Kiselev
9f5c9d568d chore: update wails bindings 2026-02-10 15:54:54 +03:00
Mikhail Kiselev
2b86eb9d20 feat: add checksum-based update detection 2026-02-10 15:53:26 +03:00
Mikhail Kiselev
75dc03b0fd fix: clean dist/ before release build 2026-02-10 15:47:48 +03:00
Mikhail Kiselev
a81540646e feat: add auto-update functionality 2026-02-10 15:39:17 +03:00
18 changed files with 1647 additions and 72 deletions

View File

@@ -1,17 +1,23 @@
.PHONY: dev build clean install frontend
# Get version from git tag
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -X 'daily-timer/internal/version.Version=$(VERSION)' -X 'daily-timer/internal/version.GitCommit=$(GIT_COMMIT)' -X 'daily-timer/internal/version.BuildTime=$(BUILD_TIME)'
# Development (fixed ports: Vite 5173, Wails DevServer 34115)
dev:
wails dev -devserver localhost:34115
# Build for macOS
build:
wails build -clean
build: lint
wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Build for macOS (universal binary)
build-universal:
wails build -clean -platform darwin/universal
build-universal: lint
wails build -clean -platform darwin/universal -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Install frontend dependencies
@@ -51,21 +57,20 @@ deps:
init: deps frontend
@echo "Project initialized. Run 'make dev' to start development."
# Get version from git tag
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
# Release - build and package
release:
release: lint
@echo "Building release $(VERSION)..."
wails build -clean
wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
@mkdir -p dist
@rm -rf dist && mkdir -p dist
cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app"
@shasum -a 256 "build/bin/Daily Timer.app/Contents/MacOS/daily-timer" | awk '{print $$1}' > "dist/Daily-Timer-$(VERSION)-macos-arm64.sha256"
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
@ls -lh dist/*.zip
@echo "Checksum: $$(cat dist/Daily-Timer-$(VERSION)-macos-arm64.sha256)"
@ls -lh dist/*
# Release for both architectures
release-all:
release-all: lint
@echo "Building release $(VERSION) for all platforms..."
@mkdir -p dist
GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64
@@ -86,7 +91,7 @@ release-upload:
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
| jq -r '.id'); \
echo "Created release ID: $$RELEASE_ID"; \
for file in dist/*.zip; do \
for file in dist/*; do \
filename=$$(basename "$$file"); \
echo "Uploading $$filename..."; \
curl -s -X POST \

View File

@@ -15,6 +15,7 @@
- 💾 **Экспорт** - экспорт данных в JSON или CSV
- 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения
- 🌐 **Локализация** - русский и английский интерфейс
- 🔄 **Автообновление** - проверка и установка обновлений из приложения
## Скриншоты
@@ -36,6 +37,24 @@ _(Скоро)_
3. Переместить `Daily Timer.app` в `/Applications/`
4. Убрать атрибут карантина (см. ниже)
### Автообновление
Приложение поддерживает автоматическое обновление:
1. Открыть **Настройки** → секция "Обновления"
2. Нажать **Проверить сейчас**
3. Если доступно обновление → **Скачать и установить**
4. После установки нажать **Перезапустить**
Приложение автоматически:
- Скачивает новую версию
- Устанавливает в `/Applications/`
- Снимает карантин (`xattr -cr`)
- Перезапускается
**Обнаружение пересборок**: Если версия та же, но бинарник изменился (пересборка) - приложение также предложит обновление.
### Установка Wails CLI
```bash
@@ -86,8 +105,9 @@ xattr -cr "Daily Timer.app"
2. Таймер показывает текущего спикера с обратным отсчётом
3. Нажать **Следующий** для перехода (или ⌘N)
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
5. Использовать **Пауза/Продолжить** для прерываний
6. Нажать **Стоп** для досрочного завершения
5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
6. Использовать **Пауза/Продолжить** для прерываний
7. Нажать **Стоп** для досрочного завершения
### Горячие клавиши
@@ -172,6 +192,7 @@ GITEA_TOKEN=<token> make release-publish
- [ ] Шаблоны команд
- [ ] Облачная синхронизация
- [ ] Поддержка Windows/Linux
- [x] Автообновление приложения
## Лицензия

View File

@@ -7,7 +7,7 @@
import History from './components/History.svelte'
import Setup from './components/Setup.svelte'
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } from '../wailsjs/go/app/App'
import { t, initLocale } from './lib/i18n'
let currentView = 'main'
@@ -35,6 +35,27 @@
EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged)
// Warm up AudioContext on first user interaction
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
document.removeEventListener('keydown', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
document.addEventListener('keydown', warmUpAudio)
})
async function loadSettings() {
@@ -80,17 +101,17 @@
timerState = state
}
function handleWarning(state) {
async function handleWarning(state) {
timerState = state
if (settings?.soundEnabled) {
playSound('warning')
await playSound('warning')
}
}
function handleTimeUp(state) {
async function handleTimeUp(state) {
timerState = state
if (settings?.soundEnabled) {
playSound('timeup')
await playSound('timeup')
}
}
@@ -98,11 +119,11 @@
timerState = state
}
function handleMeetingEnded(state) {
async function handleMeetingEnded(state) {
timerState = state
meetingActive = false
if (settings?.soundEnabled) {
playSound('meeting_end')
await playSound('meeting_end')
}
}
@@ -141,24 +162,27 @@
}
}
function playSound(name) {
async function playSound(name) {
// Ensure AudioContext is running (may be suspended after inactivity)
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
switch (name) {
case 'warning':
// Two short warning beeps
playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200)
break
case 'timeup':
// Descending tone sequence
playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500)
break
case 'meeting_end':
// Final chime - three notes
playBeep(523, 0.2) // C5
setTimeout(() => playBeep(659, 0.2), 200) // E5
setTimeout(() => playBeep(784, 0.4), 400) // G5
playBeep(523, 0.2)
setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(784, 0.4), 400)
break
}
}
@@ -168,16 +192,30 @@
currentView = 'main'
}
function handleSettingsLoaded(s) {
settings = s
function handleSettingsLoaded(event) {
settings = event.detail
}
async function handleSkipFromList(event) {
const { speakerId } = event.detail
try {
await RemoveFromQueue(speakerId)
// If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
if (timerState?.currentSpeakerId === speakerId) {
await SkipSpeaker()
} else {
await RemoveFromQueue(speakerId)
}
} catch (e) {
console.error('Failed to remove speaker from queue:', e)
console.error('Failed to skip speaker:', e)
}
}
async function handleSwitchSpeaker(event) {
const { speakerId } = event.detail
try {
await SwitchToSpeaker(speakerId)
} catch (e) {
console.error('Failed to switch to speaker:', e)
}
}
@@ -242,12 +280,12 @@
</button>
</nav>
<div class="content">
<div class="content" class:no-nav={meetingActive}>
{#if currentView === 'main'}
{#if meetingActive && timerState}
<div class="timer-view">
<Timer {timerState} />
<ParticipantList {timerState} on:skip={handleSkipFromList} />
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<Controls {timerState} on:stop={() => meetingActive = false} />
</div>
{:else if participants.length > 0}
@@ -309,6 +347,11 @@
}
.nav {
position: fixed;
top: 32px;
left: 0;
right: 0;
z-index: 100;
display: flex;
gap: 4px;
padding: 8px 12px;
@@ -335,6 +378,7 @@
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
outline: none;
}
.nav button:hover {
@@ -348,10 +392,17 @@
}
.content {
flex: 1;
overflow: auto;
position: fixed;
top: 84px; /* 32px titlebar + 52px nav height */
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
padding: 12px;
padding-bottom: 64px;
}
.content.no-nav {
top: 32px; /* Only titlebar when nav is hidden */
}
.timer-view {

View File

@@ -30,8 +30,33 @@
await StopMeeting()
dispatch('stop')
}
function handleKeydown(e) {
// ⌘N - Next speaker
if (e.metaKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
handleNext()
}
// ⌘S - Skip speaker
if (e.metaKey && e.key.toLowerCase() === 's') {
e.preventDefault()
handleSkip()
}
// Space - Pause/Resume
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault()
handlePauseResume()
}
// ⌘Q - Stop meeting
if (e.metaKey && e.key.toLowerCase() === 'q') {
e.preventDefault()
handleStop()
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="controls">
<button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')}

View File

@@ -205,7 +205,15 @@
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
<span class="log-duration">{formatTime(log.duration)}</span>
<span class="log-duration">
<span class:overtime={log.duration > (log.participant?.timeLimit || 0)}>
{formatTime(log.duration)}
</span>
{#if log.participant?.timeLimit}
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(log.participant.timeLimit)}</span>
{/if}
</span>
{#if log.overtime}
<span class="overtime-icon">⚠️</span>
{/if}
@@ -463,6 +471,22 @@
.log-duration {
color: #9ca3af;
font-family: 'SF Mono', 'Menlo', monospace;
white-space: nowrap;
display: inline-flex;
align-items: center;
}
.log-duration .overtime {
color: #ef4444;
}
.log-duration .time-sep {
color: #6b7280;
margin: 0 3px;
}
.log-duration .time-limit {
color: #6b7280;
}
.loading, .empty {

View File

@@ -1,31 +1,89 @@
<script>
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, tick } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
let listEl
let lastSpeakingOrder = 0
$: allSpeakers = timerState?.allSpeakers || []
$: currentSpeakerId = timerState?.currentSpeakerId || 0
$: currentElapsed = timerState?.speakerElapsed || 0
$: speakingOrder = timerState?.speakingOrder || 0
// Auto-scroll when speaker changes
$: if (speakingOrder !== lastSpeakingOrder && speakingOrder > 0) {
lastSpeakingOrder = speakingOrder
scrollToCurrentSpeaker()
}
async function scrollToCurrentSpeaker() {
await tick() // Wait for DOM update
if (!listEl) return
// Find the index of the current speaking participant
const speakingIndex = allSpeakers.findIndex(s => s.status === 'speaking')
if (speakingIndex < 0) return
// Scroll to show previous speaker at top (or current if first)
const targetIndex = Math.max(0, speakingIndex - 1)
const items = listEl.querySelectorAll('li')
if (items[targetIndex]) {
items[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function handleSkip(speakerId) {
dispatch('skip', { speakerId })
}
function handleSwitch(speakerId) {
dispatch('switch', { speakerId })
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>
<div class="participant-list">
<h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0}
<ul>
<ul bind:this={listEl}>
{#each allSpeakers as speaker}
<li class="speaker-item {speaker.status}">
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<li
class="speaker-item {speaker.status}"
class:clickable={speaker.status !== 'speaking'}
on:click={() => speaker.status !== 'speaking' && handleSwitch(speaker.id)}
>
<span class="order">{speaker.order}</span>
<span class="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
<span class="time-display">
{#if speaker.status === 'done'}
<span class="time-spent" class:overtime={speaker.timeSpent > speaker.timeLimit}>
{formatTime(speaker.timeSpent)}
</span>
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{:else if speaker.status === 'speaking'}
<span class="time-spent" class:overtime={currentElapsed > speaker.timeLimit}>
{formatTime(currentElapsed)}
</span>
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{:else}
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{/if}
</span>
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
</button>
{/if}
@@ -85,6 +143,18 @@
background: #1b2636;
}
.speaker-item.clickable {
cursor: pointer;
}
.speaker-item.clickable:hover {
background: #2d3f52;
}
.speaker-item.done.clickable:hover {
background: #2a4a6f;
}
.speaker-item.skipped {
background: repeating-linear-gradient(
45deg,
@@ -129,13 +199,6 @@
white-space: nowrap;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn {
margin-left: 8px;
padding: 4px 8px;
@@ -163,4 +226,30 @@
text-align: center;
padding: 24px;
}
.time-display {
display: inline-flex;
align-items: center;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.time-spent {
color: #4ade80;
}
.time-spent.overtime {
color: #ef4444;
}
.time-sep {
color: #6b7280;
margin: 0 3px;
}
.time-limit {
color: #6b7280;
}
</style>

View File

@@ -1,7 +1,7 @@
<script>
import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp, SelectCustomSound, GetCustomSoundPath, ClearCustomSound } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll, EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
import { t, locale, setLocale } from '../lib/i18n'
const dispatch = createEventDispatcher()
@@ -16,6 +16,23 @@
let windowFullHeight = true
let audioContext = null
// Custom sounds state
let customSounds = {
warning: null,
timeup: null,
meeting_end: null
}
let audioElements = {}
// Update state
let currentVersion = 'dev'
let updateInfo = null
let checkingUpdate = false
let downloadingUpdate = false
let downloadProgress = 0
let updateError = null
let updateComplete = false
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
@@ -42,11 +59,17 @@
oscillator.stop(ctx.currentTime + duration)
} catch (e) {
console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
}
}
function testSound(name) {
// If custom sound exists, play it
if (customSounds[name]) {
playCustomSound(name)
return
}
// Otherwise play default beep sounds
switch (name) {
case 'warning':
playBeep(880, 0.15)
@@ -65,10 +88,140 @@
}
}
function playCustomSound(name) {
try {
if (!audioElements[name]) {
audioElements[name] = new Audio('file://' + customSounds[name])
}
audioElements[name].currentTime = 0
audioElements[name].play()
} catch (e) {
console.error('Failed to play custom sound:', e)
}
}
async function loadCustomSounds() {
const types = ['warning', 'timeup', 'meeting_end']
for (const type of types) {
try {
const path = await GetCustomSoundPath(type)
if (path) {
customSounds[type] = path
// Pre-create audio element
audioElements[type] = new Audio('file://' + path)
}
} catch (e) {
console.error(`Failed to get custom sound for ${type}:`, e)
}
}
customSounds = { ...customSounds } // Trigger reactivity
}
async function handleUploadSound(soundType) {
try {
const path = await SelectCustomSound(soundType)
if (path) {
customSounds[soundType] = path
// Recreate audio element with new file
audioElements[soundType] = new Audio('file://' + path)
customSounds = { ...customSounds }
}
} catch (e) {
console.error('Failed to upload sound:', e)
}
}
async function handleClearSound(soundType) {
try {
await ClearCustomSound(soundType)
customSounds[soundType] = null
delete audioElements[soundType]
customSounds = { ...customSounds }
} catch (e) {
console.error('Failed to clear sound:', e)
}
}
onMount(async () => {
await loadData()
await loadCustomSounds()
// Load version and check for updates
try {
currentVersion = await GetVersion()
checkForUpdates()
} catch (e) {
console.error('Failed to get version:', e)
}
// Listen for update progress events
EventsOn('update:progress', (progress) => {
downloadProgress = progress
})
EventsOn('update:complete', () => {
downloadingUpdate = false
updateComplete = true
})
// Warm up AudioContext on first user interaction (for sound tests)
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
return () => {
EventsOff('update:progress')
EventsOff('update:complete')
document.removeEventListener('click', warmUpAudio)
}
})
async function checkForUpdates() {
checkingUpdate = true
updateError = null
updateInfo = null
try {
updateInfo = await CheckForUpdates()
} catch (e) {
console.error('Failed to check for updates:', e)
updateError = e.message || 'Unknown error'
} finally {
checkingUpdate = false
}
}
async function downloadAndInstall() {
downloadingUpdate = true
downloadProgress = 0
updateError = null
try {
await DownloadAndInstallUpdate()
} catch (e) {
console.error('Failed to download update:', e)
updateError = e.message || 'Download failed'
downloadingUpdate = false
}
}
async function restartApp() {
await RestartApp()
}
async function loadData() {
loading = true
try {
@@ -183,10 +336,60 @@
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div>
<div class="sound-test-buttons">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button>
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
<div class="sound-items">
<div class="sound-item">
<div class="sound-info">
<span class="sound-label">🔔 {$t('settings.testWarning')}</span>
{#if customSounds.warning}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('warning')}>📁</button>
{#if customSounds.warning}
<button type="button" class="clear-btn" on:click={() => handleClearSound('warning')}>✕</button>
{/if}
</div>
</div>
<div class="sound-item">
<div class="sound-info">
<span class="sound-label">{$t('settings.testTimeUp')}</span>
{#if customSounds.timeup}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('timeup')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('timeup')}>📁</button>
{#if customSounds.timeup}
<button type="button" class="clear-btn" on:click={() => handleClearSound('timeup')}>✕</button>
{/if}
</div>
</div>
<div class="sound-item">
<div class="sound-info">
<span class="sound-label">🏁 {$t('settings.testMeetingEnd')}</span>
{#if customSounds.meeting_end}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('meeting_end')}>📁</button>
{#if customSounds.meeting_end}
<button type="button" class="clear-btn" on:click={() => handleClearSound('meeting_end')}>✕</button>
{/if}
</div>
</div>
</div>
</section>
@@ -204,6 +407,70 @@
</div>
</section>
<section class="updates-section">
<h2>{$t('updates.title')}</h2>
<div class="version-info">
<span class="version-label">{$t('updates.currentVersion')}:</span>
<span class="version-value">{currentVersion}</span>
</div>
{#if checkingUpdate}
<div class="update-status checking">
<span class="spinner"></span>
{$t('updates.checkingForUpdates')}
</div>
{:else if updateError}
<div class="update-status error">
{$t('updates.error')}: {updateError}
</div>
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{:else if updateComplete}
<div class="update-status success">
{$t('updates.restartRequired')}
</div>
<div class="update-buttons">
<button class="update-btn primary" on:click={restartApp}>
{$t('updates.restart')}
</button>
<button class="update-btn" on:click={() => updateComplete = false}>
{$t('updates.later')}
</button>
</div>
{:else if downloadingUpdate}
<div class="update-status downloading">
{$t('updates.downloading')} {Math.round(downloadProgress * 100)}%
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {downloadProgress * 100}%"></div>
</div>
{:else if updateInfo?.available}
<div class="update-status available">
{#if updateInfo.isRebuild}
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{:else}
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{/if}
</div>
<button class="update-btn primary" on:click={downloadAndInstall}>
{$t('updates.downloadAndInstall')}
</button>
{:else if updateInfo}
<div class="update-status uptodate">
{$t('updates.upToDate')}
</div>
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{:else}
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{/if}
</section>
<button class="save-btn" on:click={saveSettings} disabled={saving}>
{saving ? $t('common.loading') : $t('settings.save')}
</button>
@@ -369,4 +636,204 @@
.test-btn:active {
transform: scale(0.97);
}
/* Sound items */
.sound-items {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.sound-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 8px;
}
.sound-info {
display: flex;
align-items: center;
gap: 10px;
}
.sound-label {
font-size: 14px;
color: #e0e0e0;
}
.sound-status {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
.sound-status.custom {
background: #2a4a3a;
color: #6ee7b7;
}
.sound-status.default {
background: #3d4f61;
color: #9ca3af;
}
.sound-actions {
display: flex;
gap: 6px;
}
.sound-actions button {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #3d4f61;
border-radius: 6px;
background: #2a3a4e;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.sound-actions button:hover {
border-color: #4a90d9;
background: #3a4a5e;
color: #e0e0e0;
}
.sound-actions .clear-btn:hover {
border-color: #ef4444;
background: #4a2a2a;
color: #fca5a5;
}
/* Updates section */
.updates-section {
border: 1px solid #3d4f61;
}
.version-info {
display: flex;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.version-label {
color: #9ca3af;
}
.version-value {
color: #4a90d9;
font-family: monospace;
}
.update-status {
padding: 10px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.update-status.checking {
background: #1b2636;
color: #9ca3af;
display: flex;
align-items: center;
gap: 8px;
}
.update-status.error {
background: #7f1d1d;
color: #fca5a5;
}
.update-status.available {
background: #164e63;
color: #67e8f9;
}
.update-status.uptodate {
background: #14532d;
color: #86efac;
}
.update-status.downloading {
background: #1e3a5f;
color: #93c5fd;
}
.update-status.success {
background: #14532d;
color: #86efac;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #4a90d9;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-bar {
width: 100%;
height: 8px;
background: #1b2636;
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: #4a90d9;
transition: width 0.3s ease;
}
.update-buttons {
display: flex;
gap: 8px;
}
.update-btn {
padding: 10px 16px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.update-btn:hover {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.update-btn.primary {
background: #4a90d9;
border-color: #4a90d9;
color: white;
}
.update-btn.primary:hover {
background: #3b7dc9;
border-color: #3b7dc9;
}
</style>

View File

@@ -94,6 +94,11 @@ export const translations = {
sound: 'Звуковые уведомления',
soundEnabled: 'Включены',
soundDisabled: 'Выключены',
testWarning: 'Предупреждение',
testTimeUp: 'Время вышло',
testMeetingEnd: 'Конец собрания',
customSound: 'свой',
defaultSound: 'стандартный',
warningTime: 'Предупреждение за',
seconds: 'сек',
defaultSpeakerTime: 'Время на спикера по умолчанию',
@@ -108,6 +113,25 @@ export const translations = {
windowFullHeight: 'Окно на всю высоту экрана',
},
// Updates
updates: {
title: 'Обновления',
currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...',
installing: 'Установка...',
restartRequired: 'Для завершения обновления требуется перезапуск',
restart: 'Перезапустить',
later: 'Позже',
error: 'Ошибка проверки обновлений',
downloadError: 'Ошибка загрузки обновления',
checkNow: 'Проверить сейчас',
},
// Participant management
participants: {
title: 'Управление участниками',
@@ -243,6 +267,11 @@ export const translations = {
sound: 'Sound Notifications',
soundEnabled: 'Enabled',
soundDisabled: 'Disabled',
testWarning: 'Warning',
testTimeUp: 'Time Up',
testMeetingEnd: 'Meeting End',
customSound: 'custom',
defaultSound: 'default',
warningTime: 'Warning before',
seconds: 'sec',
defaultSpeakerTime: 'Default Speaker Time',
@@ -257,6 +286,25 @@ export const translations = {
windowFullHeight: 'Full screen height window',
},
// Updates
updates: {
title: 'Updates',
currentVersion: 'Current version',
checkingForUpdates: 'Checking for updates...',
updateAvailable: 'Update available',
rebuildAvailable: 'Rebuild available',
upToDate: 'You have the latest version',
downloadAndInstall: 'Download and install',
downloading: 'Downloading...',
installing: 'Installing...',
restartRequired: 'Restart required to complete the update',
restart: 'Restart',
later: 'Later',
error: 'Error checking for updates',
downloadError: 'Error downloading update',
checkNow: 'Check now',
},
// Participant management
participants: {
title: 'Manage Participants',

View File

@@ -1,19 +1,28 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
import {updater} from '../models';
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>;
export function CheckForUpdates():Promise<updater.UpdateInfo>;
export function ClearCustomSound(arg1:string):Promise<void>;
export function DeleteAllSessions():Promise<void>;
export function DeleteParticipant(arg1:number):Promise<void>;
export function DeleteSession(arg1:number):Promise<void>;
export function DownloadAndInstallUpdate():Promise<void>;
export function ExportCSV(arg1:string,arg2:string):Promise<string>;
export function ExportData(arg1:string,arg2:string):Promise<string>;
export function GetCustomSoundPath(arg1:string):Promise<string>;
export function GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>;
@@ -30,6 +39,8 @@ export function GetStatistics(arg1:string,arg2:string):Promise<models.Aggregated
export function GetTimerState():Promise<models.TimerState>;
export function GetVersion():Promise<string>;
export function NextSpeaker():Promise<void>;
export function PauseMeeting():Promise<void>;
@@ -38,14 +49,20 @@ export function RemoveFromQueue(arg1:number):Promise<void>;
export function ReorderParticipants(arg1:Array<number>):Promise<void>;
export function RestartApp():Promise<void>;
export function ResumeMeeting():Promise<void>;
export function SelectCustomSound(arg1:string):Promise<string>;
export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():Promise<void>;
export function SwitchToSpeaker(arg1:number):Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;

View File

@@ -6,6 +6,14 @@ export function AddParticipant(arg1, arg2, arg3) {
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3);
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function ClearCustomSound(arg1) {
return window['go']['app']['App']['ClearCustomSound'](arg1);
}
export function DeleteAllSessions() {
return window['go']['app']['App']['DeleteAllSessions']();
}
@@ -18,6 +26,10 @@ export function DeleteSession(arg1) {
return window['go']['app']['App']['DeleteSession'](arg1);
}
export function DownloadAndInstallUpdate() {
return window['go']['app']['App']['DownloadAndInstallUpdate']();
}
export function ExportCSV(arg1, arg2) {
return window['go']['app']['App']['ExportCSV'](arg1, arg2);
}
@@ -26,6 +38,10 @@ export function ExportData(arg1, arg2) {
return window['go']['app']['App']['ExportData'](arg1, arg2);
}
export function GetCustomSoundPath(arg1) {
return window['go']['app']['App']['GetCustomSoundPath'](arg1);
}
export function GetMeeting() {
return window['go']['app']['App']['GetMeeting']();
}
@@ -58,6 +74,10 @@ export function GetTimerState() {
return window['go']['app']['App']['GetTimerState']();
}
export function GetVersion() {
return window['go']['app']['App']['GetVersion']();
}
export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker']();
}
@@ -74,10 +94,18 @@ export function ReorderParticipants(arg1) {
return window['go']['app']['App']['ReorderParticipants'](arg1);
}
export function RestartApp() {
return window['go']['app']['App']['RestartApp']();
}
export function ResumeMeeting() {
return window['go']['app']['App']['ResumeMeeting']();
}
export function SelectCustomSound(arg1) {
return window['go']['app']['App']['SelectCustomSound'](arg1);
}
export function SkipSpeaker() {
return window['go']['app']['App']['SkipSpeaker']();
}
@@ -90,6 +118,10 @@ export function StopMeeting() {
return window['go']['app']['App']['StopMeeting']();
}
export function SwitchToSpeaker(arg1) {
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
}
export function UpdateMeeting(arg1, arg2) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
}

View File

@@ -351,6 +351,7 @@ export namespace models {
id: number;
name: string;
timeLimit: number;
timeSpent: number;
order: number;
status: string;
@@ -363,6 +364,7 @@ export namespace models {
this.id = source["id"];
this.name = source["name"];
this.timeLimit = source["timeLimit"];
this.timeSpent = source["timeSpent"];
this.order = source["order"];
this.status = source["status"];
}
@@ -432,3 +434,32 @@ export namespace models {
}
export namespace updater {
export class UpdateInfo {
available: boolean;
currentVersion: string;
latestVersion: string;
releaseNotes: string;
downloadURL: string;
downloadSize: number;
isRebuild: boolean;
static createFrom(source: any = {}) {
return new UpdateInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.currentVersion = source["currentVersion"];
this.latestVersion = source["latestVersion"];
this.releaseNotes = source["releaseNotes"];
this.downloadURL = source["downloadURL"];
this.downloadSize = source["downloadSize"];
this.isRebuild = source["isRebuild"];
}
}
}

View File

@@ -9,8 +9,10 @@ import (
"time"
"daily-timer/internal/models"
"daily-timer/internal/services/updater"
"daily-timer/internal/storage"
"daily-timer/internal/timer"
"daily-timer/internal/version"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -21,12 +23,14 @@ type App struct {
timer *timer.Timer
session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog
updater *updater.Updater
}
func New(store *storage.Storage) *App {
return &App{
store: store,
currentLogs: make(map[uint]*models.ParticipantLog),
updater: updater.New(),
}
}
@@ -35,10 +39,19 @@ func (a *App) Startup(ctx context.Context) {
}
func (a *App) OnDomReady(ctx context.Context) {
// Restore saved window position
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
if settings.WindowX >= 0 && settings.WindowY >= 0 {
runtime.WindowSetPosition(ctx, settings.WindowX, settings.WindowY)
}
}
runtime.WindowShow(ctx)
}
func (a *App) Shutdown(ctx context.Context) {
// Save window position before closing
a.saveWindowPosition()
if a.timer != nil {
a.timer.Close()
}
@@ -47,6 +60,25 @@ func (a *App) Shutdown(ctx context.Context) {
}
}
func (a *App) saveWindowPosition() {
if a.ctx == nil {
return
}
x, y := runtime.WindowGetPosition(a.ctx)
if x >= 0 && y >= 0 {
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
settings.WindowX = x
settings.WindowY = y
_ = a.store.UpdateSettings(settings)
}
}
}
// SaveWindowPosition saves current window position (can be called from frontend)
func (a *App) SaveWindowPosition() {
a.saveWindowPosition()
}
// Participants
func (a *App) GetParticipants() ([]models.Participant, error) {
@@ -175,13 +207,37 @@ func (a *App) handleTimerEvents() {
a.saveSpeakerLog(event.State)
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
case timer.EventMeetingEnded:
a.saveSpeakerLog(event.State)
a.finalizeSpeakerLogs(event.State)
a.endMeetingSession(event.State)
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
}
}
}
func (a *App) finalizeSpeakerLogs(state models.TimerState) {
if a.session == nil {
return
}
// Only finalize existing logs, don't create new ones
for id, log := range a.currentLogs {
if log.EndedAt == nil {
now := time.Now()
log.EndedAt = &now
log.Duration = int(now.Sub(log.StartedAt).Seconds())
participants, _ := a.store.GetParticipants()
for _, p := range participants {
if p.ID == id {
log.Overtime = log.Duration > p.TimeLimit
break
}
}
_ = a.store.UpdateParticipantLog(log)
}
}
}
func (a *App) saveSpeakerLog(state models.TimerState) {
if a.session == nil {
return
@@ -243,6 +299,12 @@ func (a *App) RemoveFromQueue(speakerID uint) {
}
}
func (a *App) SwitchToSpeaker(speakerID uint) {
if a.timer != nil {
a.timer.SwitchToSpeaker(speakerID)
}
}
func (a *App) PauseMeeting() {
if a.timer != nil {
a.timer.Pause()
@@ -479,3 +541,130 @@ func (a *App) GetSoundsDir() string {
_ = os.MkdirAll(soundsDir, 0755)
return soundsDir
}
// Updates
func (a *App) GetVersion() string {
return version.Version
}
func (a *App) CheckForUpdates() (*updater.UpdateInfo, error) {
return a.updater.CheckForUpdates()
}
func (a *App) DownloadAndInstallUpdate() error {
err := a.updater.DownloadAndInstall(func(progress float64) {
runtime.EventsEmit(a.ctx, "update:progress", progress)
})
if err != nil {
return err
}
runtime.EventsEmit(a.ctx, "update:complete", true)
return nil
}
func (a *App) RestartApp() error {
return a.updater.RestartApp()
}
// Sound Management
func (a *App) getSoundsDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
if err := os.MkdirAll(soundsDir, 0755); err != nil {
return "", err
}
return soundsDir, nil
}
func (a *App) SelectCustomSound(soundType string) (string, error) {
// Validate sound type
validTypes := map[string]bool{"warning": true, "timeup": true, "meeting_end": true}
if !validTypes[soundType] {
return "", fmt.Errorf("invalid sound type: %s", soundType)
}
// Open file dialog
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Sound File",
Filters: []runtime.FileFilter{
{DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.m4a;*.ogg"},
},
})
if err != nil {
return "", err
}
if selection == "" {
return "", nil // User cancelled
}
// Get sounds directory
soundsDir, err := a.getSoundsDir()
if err != nil {
return "", err
}
// Determine destination filename
ext := filepath.Ext(selection)
destPath := filepath.Join(soundsDir, soundType+ext)
// Copy file
src, err := os.Open(selection)
if err != nil {
return "", err
}
defer func() { _ = src.Close() }()
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() { _ = dst.Close() }()
if _, err := dst.ReadFrom(src); err != nil {
return "", err
}
return destPath, nil
}
func (a *App) GetCustomSoundPath(soundType string) string {
soundsDir, err := a.getSoundsDir()
if err != nil {
return ""
}
// Check for common audio extensions
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func (a *App) ClearCustomSound(soundType string) error {
soundsDir, err := a.getSoundsDir()
if err != nil {
return err
}
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return err
}
}
}
return nil
}

View File

@@ -67,6 +67,8 @@ type Settings struct {
SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
Theme string `json:"theme" gorm:"default:dark"`
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
WindowX int `json:"windowX" gorm:"default:-1"` // -1 = not set (center)
WindowY int `json:"windowY" gorm:"default:-1"` // -1 = not set (center)
}

View File

@@ -33,6 +33,7 @@ type SpeakerInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
TimeLimit int `json:"timeLimit"`
TimeSpent int `json:"timeSpent"`
Order int `json:"order"`
Status SpeakerStatus `json:"status"`
}

View File

@@ -0,0 +1,428 @@
package updater
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"daily-timer/internal/version"
)
const (
GiteaAPIURL = "https://git.movida.biz/api/v1/repos/bell/daily-timer/releases/latest"
AppName = "Daily Timer.app"
InstallPath = "/Applications"
DownloadPrefix = "Daily-Timer-"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
}
type Updater struct {
latestRelease *Release
downloadURL string
}
func New() *Updater {
return &Updater{}
}
func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(GiteaAPIURL)
if err != nil {
return nil, fmt.Errorf("failed to check for updates: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse release info: %w", err)
}
u.latestRelease = &release
// Find macOS arm64 asset
var downloadAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".zip") {
downloadAsset = &release.Assets[i]
break
}
}
if downloadAsset == nil {
return nil, fmt.Errorf("no macOS arm64 asset found")
}
u.downloadURL = downloadAsset.BrowserDownloadURL
// Find checksum asset
var checksumAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".sha256") {
checksumAsset = &release.Assets[i]
break
}
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "v")
isNewer := isNewerVersion(latestVersion, currentVersion)
isRebuild := false
// Check if same version but different checksum (rebuild)
if !isNewer && checksumAsset != nil {
remoteChecksum, err := u.downloadChecksum(checksumAsset.BrowserDownloadURL)
if err == nil {
localChecksum, err := u.calculateBinaryChecksum()
if err == nil && remoteChecksum != localChecksum {
isRebuild = true
}
}
}
info := &UpdateInfo{
Available: isNewer || isRebuild,
CurrentVersion: version.Version,
LatestVersion: release.TagName,
ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
}
return info, nil
}
func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
if u.downloadURL == "" {
return fmt.Errorf("no download URL available, run CheckForUpdates first")
}
// Create temp directory
tmpDir, err := os.MkdirTemp("", "daily-timer-update-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
zipPath := filepath.Join(tmpDir, "update.zip")
// Download ZIP
if err := u.downloadFile(zipPath, progressCallback); err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
// Extract ZIP
extractPath := filepath.Join(tmpDir, "extracted")
if err := u.extractZip(zipPath, extractPath); err != nil {
return fmt.Errorf("failed to extract update: %w", err)
}
// Find .app in extracted folder
appPath := filepath.Join(extractPath, AppName)
if _, err := os.Stat(appPath); os.IsNotExist(err) {
return fmt.Errorf("app not found in update package")
}
// Remove old app from /Applications
destPath := filepath.Join(InstallPath, AppName)
if _, err := os.Stat(destPath); err == nil {
if err := os.RemoveAll(destPath); err != nil {
return fmt.Errorf("failed to remove old app: %w", err)
}
}
// Copy new app to /Applications
if err := u.copyDir(appPath, destPath); err != nil {
return fmt.Errorf("failed to install update: %w", err)
}
// Remove quarantine attribute
cmd := exec.Command("xattr", "-cr", destPath)
if err := cmd.Run(); err != nil {
fmt.Printf("Warning: failed to remove quarantine: %v\n", err)
}
return nil
}
func (u *Updater) RestartApp() error {
destPath := filepath.Join(InstallPath, AppName)
// Use shell to launch new app after this process exits
// The sleep ensures the current app has time to exit
script := fmt.Sprintf(`sleep 1 && open "%s"`, destPath)
cmd := exec.Command("sh", "-c", script)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
// Detach the process so it continues after we exit
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch updated app: %w", err)
}
// Release the process so it doesn't become a zombie
go func() {
_ = cmd.Wait()
}()
// Give the shell time to start
time.Sleep(100 * time.Millisecond)
// Exit current app
os.Exit(0)
return nil
}
func (u *Updater) downloadFile(destPath string, progressCallback func(float64)) error {
resp, err := http.Get(u.downloadURL)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
out, err := os.Create(destPath)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
totalSize := resp.ContentLength
var downloaded int64 = 0
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if progressCallback != nil && totalSize > 0 {
progressCallback(float64(downloaded) / float64(totalSize))
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func (u *Updater) extractZip(zipPath, destPath string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer func() { _ = r.Close() }()
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
for _, f := range r.File {
fpath := filepath.Join(destPath, f.Name)
// Prevent ZipSlip vulnerability
if !strings.HasPrefix(fpath, filepath.Clean(destPath)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", fpath)
}
if f.FileInfo().IsDir() {
_ = os.MkdirAll(fpath, f.Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
_ = outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
_ = outFile.Close()
_ = rc.Close()
if err != nil {
return err
}
}
return nil
}
func (u *Updater) copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
// Handle symlinks
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, dstPath)
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = srcFile.Close() }()
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer func() { _ = dstFile.Close() }()
_, err = io.Copy(dstFile, srcFile)
return err
})
}
// downloadChecksum fetches the remote SHA256 checksum file
func (u *Updater) downloadChecksum(url string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download checksum: status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// calculateBinaryChecksum calculates SHA256 of the current running binary
func (u *Updater) calculateBinaryChecksum() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
// Resolve symlinks to get actual binary path
execPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
return "", err
}
file, err := os.Open(execPath)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
// isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
func isNewerVersion(latest, current string) bool {
if current == "dev" || current == "unknown" {
return true
}
latest = strings.TrimPrefix(latest, "v")
current = strings.TrimPrefix(current, "v")
// Handle dirty versions (e.g., "0.1.0-dirty" or "0.1.0-3-g1234567")
if strings.Contains(current, "-") {
parts := strings.Split(current, "-")
current = parts[0]
}
latestParts := strings.Split(latest, ".")
currentParts := strings.Split(current, ".")
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
var l, c int
_, _ = fmt.Sscanf(latestParts[i], "%d", &l)
_, _ = fmt.Sscanf(currentParts[i], "%d", &c)
if l > c {
return true
} else if l < c {
return false
}
}
return len(latestParts) > len(currentParts)
}

View File

@@ -106,24 +106,26 @@ func (t *Timer) Start() {
t.meetingWarned = false
if len(t.queue) > 0 {
t.startNextSpeaker(now)
t.startNextSpeaker(now, 0)
}
t.mu.Unlock()
go t.tick()
}
func (t *Timer) startNextSpeaker(now time.Time) {
func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
if len(t.queue) == 0 {
return
}
// Mark previous speaker as done (only if they were speaking, not skipped)
// Mark previous speaker as done and save their time spent
if t.currentSpeakerID != 0 {
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID {
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
}
break
}
@@ -135,8 +137,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
t.currentSpeakerID = speaker.ID
t.currentSpeaker = speaker.Name
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
t.speakerStartTime = now
t.speakerElapsed = 0
t.speakerStartTime = now.Add(-offset)
t.speakerElapsed = offset
t.speakingOrder++
t.speakerWarned = false
t.speakerTimeUpEmitted = false
@@ -191,7 +193,7 @@ func (t *Timer) NextSpeaker() {
var eventType EventType
if len(t.queue) > 0 {
t.startNextSpeaker(now)
t.startNextSpeaker(now, 0)
eventType = EventSpeakerChanged
} else {
t.running = false
@@ -225,11 +227,14 @@ func (t *Timer) SkipSpeaker() {
now := time.Now()
if len(t.queue) > 1 {
t.startNextSpeaker(now)
t.startNextSpeaker(now, 0)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
} else {
// Only skipped speaker left - they need to speak now
t.startNextSpeaker(now, 0)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
}
}
@@ -246,7 +251,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
return
}
// Remove from queue
// Find speaker info before removing
var speakerInfo models.QueuedSpeaker
for _, s := range t.queue {
if s.ID == speakerID {
speakerInfo = s
break
}
}
// Remove from current position in queue
for i, s := range t.queue {
if s.ID == speakerID {
t.queue = append(t.queue[:i], t.queue[i+1:]...)
@@ -254,11 +268,124 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
}
}
// Add to end of queue so they can speak later
if speakerInfo.ID != 0 {
t.queue = append(t.queue, speakerInfo)
}
// Mark as skipped in allSpeakers and move to end
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
t.moveSpeakerToEnd(speakerID)
}
// SwitchToSpeaker moves the specified speaker to front of queue and starts them
// If speaker is already done, resumes their timer from accumulated time
func (t *Timer) SwitchToSpeaker(speakerID uint) {
t.mu.Lock()
if !t.running {
t.mu.Unlock()
return
}
// First, find speaker in allSpeakers to get their info and status
var speakerInfo *models.SpeakerInfo
var speakerInfoIdx int
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == speakerID {
speakerInfo = &t.allSpeakers[i]
speakerInfoIdx = i
break
}
}
if speakerInfo == nil {
t.mu.Unlock()
return
}
// Don't switch to currently speaking speaker
if speakerInfo.Status == models.SpeakerStatusSpeaking {
t.mu.Unlock()
return
}
// Calculate offset for resuming (0 for pending/skipped, timeSpent for done)
var offset time.Duration
if speakerInfo.Status == models.SpeakerStatusDone {
offset = time.Duration(speakerInfo.TimeSpent) * time.Second
}
// Save current speaker time
now := time.Now()
if t.currentSpeakerID != 0 {
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID {
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
}
break
}
}
}
// Find speaker in queue (pending/skipped) or create new entry (done)
foundIdx := -1
for i, s := range t.queue {
if s.ID == speakerID {
foundIdx = i
break
}
}
// Create QueuedSpeaker from SpeakerInfo
queuedSpeaker := models.QueuedSpeaker{
ID: speakerInfo.ID,
Name: speakerInfo.Name,
TimeLimit: speakerInfo.TimeLimit,
Order: speakerInfo.Order,
}
if foundIdx >= 0 {
// Remove from current position in queue
t.queue = append(t.queue[:foundIdx], t.queue[foundIdx+1:]...)
}
// Insert at front of queue
t.queue = append([]models.QueuedSpeaker{queuedSpeaker}, t.queue...)
// Move the selected speaker in allSpeakers to position after last done/speaking
insertPos := 0
for i, s := range t.allSpeakers {
if s.Status == models.SpeakerStatusDone || s.Status == models.SpeakerStatusSpeaking {
insertPos = i + 1
}
}
if speakerInfoIdx >= 0 && speakerInfoIdx != insertPos {
// Save speaker info before removing
savedInfo := *speakerInfo
// Remove from current position
t.allSpeakers = append(t.allSpeakers[:speakerInfoIdx], t.allSpeakers[speakerInfoIdx+1:]...)
// Adjust insert position if needed
if speakerInfoIdx < insertPos {
insertPos--
}
// Insert at new position
t.allSpeakers = append(t.allSpeakers[:insertPos], append([]models.SpeakerInfo{savedInfo}, t.allSpeakers[insertPos:]...)...)
// Update order numbers
for i := range t.allSpeakers {
t.allSpeakers[i].Order = i + 1
}
}
// Start this speaker with offset (0 for new speakers, accumulated time for done)
t.startNextSpeaker(now, offset)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
}
func (t *Timer) Pause() {
t.mu.Lock()
@@ -299,9 +426,17 @@ func (t *Timer) Stop() {
t.mu.Unlock()
return
}
// Mark current speaker as done before stopping
// Mark current speaker as done and save their time spent
if t.currentSpeakerID != 0 {
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone)
now := time.Now()
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID {
t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
break
}
}
}
t.running = false
t.paused = false

View File

@@ -0,0 +1,10 @@
package version
// Version is set at build time via ldflags
var Version = "dev"
// GitCommit is set at build time via ldflags
var GitCommit = "unknown"
// BuildTime is set at build time via ldflags
var BuildTime = "unknown"

View File

@@ -13,7 +13,7 @@
"info": {
"companyName": "Movida.Biz",
"productName": "Daily Timer",
"productVersion": "1.0.0",
"productVersion": "0.1.0",
"comments": "Meeting timer with participant time tracking",
"copyright": "Copyright © 2026 Movida.Biz"
}