25 Commits

Author SHA1 Message Date
Mikhail Kiselev
1620e12115 docs: mark drag-and-drop as completed 2026-02-11 00:21:32 +03:00
Mikhail Kiselev
545a18cf59 feat: global attendance store persists between views 2026-02-11 00:10:04 +03:00
Mikhail Kiselev
7e376f8211 fix: release-upload depends on release target 2026-02-10 23:58:51 +03:00
Mikhail Kiselev
c2a17185fd fix: save first speaker log on meeting start 2026-02-10 23:54:23 +03:00
Mikhail Kiselev
b2454f3e9e chore: remove unused CSS selector 2026-02-10 23:43:46 +03:00
Mikhail Kiselev
422ff362c3 chore: update wails bindings 2026-02-10 23:39:02 +03:00
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
20 changed files with 1745 additions and 101 deletions

View File

@@ -1,17 +1,23 @@
.PHONY: dev build clean install frontend .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) # Development (fixed ports: Vite 5173, Wails DevServer 34115)
dev: dev:
wails dev -devserver localhost:34115 wails dev -devserver localhost:34115
# Build for macOS # Build for macOS
build: build: lint
wails build -clean wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Build for macOS (universal binary) # Build for macOS (universal binary)
build-universal: build-universal: lint
wails build -clean -platform darwin/universal wails build -clean -platform darwin/universal -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Install frontend dependencies # Install frontend dependencies
@@ -51,21 +57,20 @@ deps:
init: deps frontend init: deps frontend
@echo "Project initialized. Run 'make dev' to start development." @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 - build and package
release: release: lint
@echo "Building release $(VERSION)..." @echo "Building release $(VERSION)..."
wails build -clean wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @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" 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" @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 for both architectures
release-all: release-all: lint
@echo "Building release $(VERSION) for all platforms..." @echo "Building release $(VERSION) for all platforms..."
@mkdir -p dist @mkdir -p dist
GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64 GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64
@@ -76,7 +81,8 @@ release-all:
@ls -lh dist/*.zip @ls -lh dist/*.zip
# Upload release to Gitea (requires GITEA_TOKEN env var) # Upload release to Gitea (requires GITEA_TOKEN env var)
release-upload: # Depends on 'release' to ensure dist/ files are up-to-date
release-upload: release
@if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi @if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi
@echo "Creating release $(VERSION) on Gitea..." @echo "Creating release $(VERSION) on Gitea..."
@RELEASE_ID=$$(curl -s -X POST \ @RELEASE_ID=$$(curl -s -X POST \
@@ -86,7 +92,7 @@ release-upload:
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \ -d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
| jq -r '.id'); \ | jq -r '.id'); \
echo "Created release ID: $$RELEASE_ID"; \ echo "Created release ID: $$RELEASE_ID"; \
for file in dist/*.zip; do \ for file in dist/*; do \
filename=$$(basename "$$file"); \ filename=$$(basename "$$file"); \
echo "Uploading $$filename..."; \ echo "Uploading $$filename..."; \
curl -s -X POST \ curl -s -X POST \
@@ -96,8 +102,8 @@ release-upload:
done done
@echo "Done!" @echo "Done!"
# Full release cycle: build + upload # Full release cycle: build + upload (release-upload already depends on release)
release-publish: release release-upload release-publish: release-upload
# Help # Help
help: help:

View File

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

View File

@@ -7,8 +7,9 @@
import History from './components/History.svelte' import History from './components/History.svelte'
import Setup from './components/Setup.svelte' import Setup from './components/Setup.svelte'
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime' 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' import { t, initLocale } from './lib/i18n'
import { attendance } from './lib/stores'
let currentView = 'main' let currentView = 'main'
let timerState = null let timerState = null
@@ -35,6 +36,27 @@
EventsOn('timer:meeting_warning', handleMeetingWarning) EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded) EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged) 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() { async function loadSettings() {
@@ -80,17 +102,17 @@
timerState = state timerState = state
} }
function handleWarning(state) { async function handleWarning(state) {
timerState = state timerState = state
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('warning') await playSound('warning')
} }
} }
function handleTimeUp(state) { async function handleTimeUp(state) {
timerState = state timerState = state
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('timeup') await playSound('timeup')
} }
} }
@@ -98,11 +120,11 @@
timerState = state timerState = state
} }
function handleMeetingEnded(state) { async function handleMeetingEnded(state) {
timerState = state timerState = state
meetingActive = false meetingActive = false
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('meeting_end') await playSound('meeting_end')
} }
} }
@@ -141,24 +163,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) { switch (name) {
case 'warning': case 'warning':
// Two short warning beeps
playBeep(880, 0.15) playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200) setTimeout(() => playBeep(880, 0.15), 200)
break break
case 'timeup': case 'timeup':
// Descending tone sequence
playBeep(1200, 0.2) playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500) setTimeout(() => playBeep(600, 0.3), 500)
break break
case 'meeting_end': case 'meeting_end':
// Final chime - three notes playBeep(523, 0.2)
playBeep(523, 0.2) // C5 setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(659, 0.2), 200) // E5 setTimeout(() => playBeep(784, 0.4), 400)
setTimeout(() => playBeep(784, 0.4), 400) // G5
break break
} }
} }
@@ -168,22 +193,37 @@
currentView = 'main' currentView = 'main'
} }
function handleSettingsLoaded(s) { function handleSettingsLoaded(event) {
settings = s settings = event.detail
} }
async function handleSkipFromList(event) { async function handleSkipFromList(event) {
const { speakerId } = event.detail const { speakerId } = event.detail
try { try {
// If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
if (timerState?.currentSpeakerId === speakerId) {
await SkipSpeaker()
} else {
await RemoveFromQueue(speakerId) await RemoveFromQueue(speakerId)
}
} catch (e) { } 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)
} }
} }
async function loadParticipants() { async function loadParticipants() {
try { try {
participants = await GetParticipants() || [] participants = await GetParticipants() || []
attendance.init(participants)
} catch (e) { } catch (e) {
console.error('Failed to load participants:', e) console.error('Failed to load participants:', e)
participants = [] participants = []
@@ -193,12 +233,16 @@
async function handleQuickStart() { async function handleQuickStart() {
if (participants.length === 0) return if (participants.length === 0) return
const ids = participants.map(p => p.id) const att = attendance.get()
const attendance = {} const presentIds = participants.filter(p => att[p.id]).map(p => p.id)
participants.forEach(p => { attendance[p.id] = true })
if (presentIds.length === 0) {
alert($t('setup.noParticipants'))
return
}
try { try {
await StartMeeting(ids, attendance) await StartMeeting(presentIds, att)
meetingActive = true meetingActive = true
} catch (e) { } catch (e) {
console.error('Failed to start meeting:', e) console.error('Failed to start meeting:', e)
@@ -242,12 +286,12 @@
</button> </button>
</nav> </nav>
<div class="content"> <div class="content" class:no-nav={meetingActive}>
{#if currentView === 'main'} {#if currentView === 'main'}
{#if meetingActive && timerState} {#if meetingActive && timerState}
<div class="timer-view"> <div class="timer-view">
<Timer {timerState} /> <Timer {timerState} />
<ParticipantList {timerState} on:skip={handleSkipFromList} /> <ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<Controls {timerState} on:stop={() => meetingActive = false} /> <Controls {timerState} on:stop={() => meetingActive = false} />
</div> </div>
{:else if participants.length > 0} {:else if participants.length > 0}
@@ -309,6 +353,11 @@
} }
.nav { .nav {
position: fixed;
top: 32px;
left: 0;
right: 0;
z-index: 100;
display: flex; display: flex;
gap: 4px; gap: 4px;
padding: 8px 12px; padding: 8px 12px;
@@ -335,6 +384,7 @@
font-size: 13px; font-size: 13px;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
outline: none;
} }
.nav button:hover { .nav button:hover {
@@ -348,10 +398,17 @@
} }
.content { .content {
flex: 1; position: fixed;
overflow: auto; top: 84px; /* 32px titlebar + 52px nav height */
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
padding: 12px; padding: 12px;
padding-bottom: 64px; }
.content.no-nav {
top: 32px; /* Only titlebar when nav is hidden */
} }
.timer-view { .timer-view {

View File

@@ -30,8 +30,33 @@
await StopMeeting() await StopMeeting()
dispatch('stop') 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> </script>
<svelte:window on:keydown={handleKeydown} />
<div class="controls"> <div class="controls">
<button class="btn primary" on:click={handleNext}> <button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')} {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}> <div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span> <span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</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} {#if log.overtime}
<span class="overtime-icon">⚠️</span> <span class="overtime-icon">⚠️</span>
{/if} {/if}
@@ -463,6 +471,22 @@
.log-duration { .log-duration {
color: #9ca3af; color: #9ca3af;
font-family: 'SF Mono', 'Menlo', monospace; 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 { .loading, .empty {

View File

@@ -1,31 +1,89 @@
<script> <script>
import { createEventDispatcher } from 'svelte' import { createEventDispatcher, tick } from 'svelte'
import { t } from '../lib/i18n' import { t } from '../lib/i18n'
export let timerState export let timerState
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let listEl
let lastSpeakingOrder = 0
$: allSpeakers = timerState?.allSpeakers || [] $: allSpeakers = timerState?.allSpeakers || []
$: currentSpeakerId = timerState?.currentSpeakerId || 0 $: 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) { function handleSkip(speakerId) {
dispatch('skip', { 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> </script>
<div class="participant-list"> <div class="participant-list">
<h3>{$t('timer.participants')}</h3> <h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0} {#if allSpeakers.length > 0}
<ul> <ul bind:this={listEl}>
{#each allSpeakers as speaker} {#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="order">{speaker.order}</span>
<span class="name">{speaker.name}</span> <span class="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span> <span class="time-display">
{#if speaker.status === 'pending' || speaker.status === 'skipped'} {#if speaker.status === 'done'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}"> <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> </button>
{/if} {/if}
@@ -85,6 +143,18 @@
background: #1b2636; background: #1b2636;
} }
.speaker-item.clickable {
cursor: pointer;
}
.speaker-item.clickable:hover {
background: #2d3f52;
}
.speaker-item.done.clickable:hover {
background: #2a4a6f;
}
.speaker-item.skipped { .speaker-item.skipped {
background: repeating-linear-gradient( background: repeating-linear-gradient(
45deg, 45deg,
@@ -129,13 +199,6 @@
white-space: nowrap; white-space: nowrap;
} }
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn { .skip-btn {
margin-left: 8px; margin-left: 8px;
padding: 4px 8px; padding: 4px 8px;
@@ -163,4 +226,30 @@
text-align: center; text-align: center;
padding: 24px; 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> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App' import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp, SelectCustomSound, GetCustomSoundPath, ClearCustomSound } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime' import { WindowSetSize, ScreenGetAll, EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
import { t, locale, setLocale } from '../lib/i18n' import { t, locale, setLocale } from '../lib/i18n'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@@ -16,6 +16,23 @@
let windowFullHeight = true let windowFullHeight = true
let audioContext = null 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() { function getAudioContext() {
if (!audioContext) { if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)() audioContext = new (window.AudioContext || window.webkitAudioContext)()
@@ -42,11 +59,17 @@
oscillator.stop(ctx.currentTime + duration) oscillator.stop(ctx.currentTime + duration)
} catch (e) { } catch (e) {
console.error('Failed to play sound:', e) console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
} }
} }
function testSound(name) { function testSound(name) {
// If custom sound exists, play it
if (customSounds[name]) {
playCustomSound(name)
return
}
// Otherwise play default beep sounds
switch (name) { switch (name) {
case 'warning': case 'warning':
playBeep(880, 0.15) playBeep(880, 0.15)
@@ -65,9 +88,139 @@
} }
} }
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 () => { onMount(async () => {
await loadData() 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() { async function loadData() {
loading = true loading = true
@@ -183,10 +336,60 @@
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label> <label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div> </div>
<div class="sound-test-buttons"> <div class="sound-items">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button> <div class="sound-item">
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button> <div class="sound-info">
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button> <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> </div>
</section> </section>
@@ -204,6 +407,70 @@
</div> </div>
</section> </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}> <button class="save-btn" on:click={saveSettings} disabled={saving}>
{saving ? $t('common.loading') : $t('settings.save')} {saving ? $t('common.loading') : $t('settings.save')}
</button> </button>
@@ -342,12 +609,6 @@
color: #6b7280; color: #6b7280;
} }
.sound-test-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.test-btn { .test-btn {
flex: 1; flex: 1;
padding: 10px 12px; padding: 10px 12px;
@@ -369,4 +630,204 @@
.test-btn:active { .test-btn:active {
transform: scale(0.97); 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> </style>

View File

@@ -2,13 +2,13 @@
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App' import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n' import { t } from '../lib/i18n'
import { attendance } from '../lib/stores'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let participants = [] let participants = []
let meeting = null let meeting = null
let selectedOrder = [] let selectedOrder = []
let attendance = {}
let loading = true let loading = true
let newName = '' let newName = ''
let newTimeLimitMin = 2 let newTimeLimitMin = 2
@@ -37,10 +37,7 @@
meeting = await GetMeeting() meeting = await GetMeeting()
selectedOrder = participants.map(p => p.id) selectedOrder = participants.map(p => p.id)
attendance = {} attendance.init(participants)
participants.forEach(p => {
attendance[p.id] = true
})
} catch (e) { } catch (e) {
console.error('Failed to load data:', e) console.error('Failed to load data:', e)
} }
@@ -95,8 +92,7 @@
} }
function toggleAttendance(id) { function toggleAttendance(id) {
attendance[id] = !attendance[id] attendance.toggle(id)
attendance = attendance
} }
// Drag and drop state // Drag and drop state
@@ -152,14 +148,15 @@
} }
async function handleStart() { async function handleStart() {
const presentIds = selectedOrder.filter(id => attendance[id]) const att = attendance.get()
const presentIds = selectedOrder.filter(id => att[id])
if (presentIds.length === 0) { if (presentIds.length === 0) {
alert($t('setup.noParticipants')) alert($t('setup.noParticipants'))
return return
} }
try { try {
await StartMeeting(presentIds, attendance) await StartMeeting(presentIds, att)
dispatch('started') dispatch('started')
} catch (e) { } catch (e) {
console.error('Failed to start meeting:', e) console.error('Failed to start meeting:', e)
@@ -314,7 +311,7 @@
{@const p = getParticipant(id)} {@const p = getParticipant(id)}
{#if p} {#if p}
<li <li
class:absent={!attendance[id]} class:absent={!$attendance[id]}
class:drag-over={dragOverId === id} class:drag-over={dragOverId === id}
draggable="true" draggable="true"
on:dragstart={(e) => handleDragStart(e, id)} on:dragstart={(e) => handleDragStart(e, id)}
@@ -329,10 +326,10 @@
<button <button
class="attendance-toggle" class="attendance-toggle"
class:present={attendance[id]} class:present={$attendance[id]}
on:click={() => toggleAttendance(id)} on:click={() => toggleAttendance(id)}
> >
{attendance[id] ? '✓' : '✗'} {$attendance[id] ? '✓' : '✗'}
</button> </button>
<span class="name">{p.name}</span> <span class="name">{p.name}</span>
@@ -375,8 +372,8 @@
{/if} {/if}
<div class="summary"> <div class="summary">
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span> <span>{$t('setup.participants')}: {Object.values($attendance).filter(Boolean).length} / {participants.length}</span>
<span>{formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span> <span>{formatTime(selectedOrder.filter(id => $attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
</div> </div>
<button class="start-btn" on:click={handleStart}> <button class="start-btn" on:click={handleStart}>

View File

@@ -94,6 +94,11 @@ export const translations = {
sound: 'Звуковые уведомления', sound: 'Звуковые уведомления',
soundEnabled: 'Включены', soundEnabled: 'Включены',
soundDisabled: 'Выключены', soundDisabled: 'Выключены',
testWarning: 'Предупреждение',
testTimeUp: 'Время вышло',
testMeetingEnd: 'Конец собрания',
customSound: 'свой',
defaultSound: 'стандартный',
warningTime: 'Предупреждение за', warningTime: 'Предупреждение за',
seconds: 'сек', seconds: 'сек',
defaultSpeakerTime: 'Время на спикера по умолчанию', defaultSpeakerTime: 'Время на спикера по умолчанию',
@@ -108,6 +113,25 @@ export const translations = {
windowFullHeight: 'Окно на всю высоту экрана', windowFullHeight: 'Окно на всю высоту экрана',
}, },
// Updates
updates: {
title: 'Обновления',
currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...',
installing: 'Установка...',
restartRequired: 'Для завершения обновления требуется перезапуск',
restart: 'Перезапустить',
later: 'Позже',
error: 'Ошибка проверки обновлений',
downloadError: 'Ошибка загрузки обновления',
checkNow: 'Проверить сейчас',
},
// Participant management // Participant management
participants: { participants: {
title: 'Управление участниками', title: 'Управление участниками',
@@ -243,6 +267,11 @@ export const translations = {
sound: 'Sound Notifications', sound: 'Sound Notifications',
soundEnabled: 'Enabled', soundEnabled: 'Enabled',
soundDisabled: 'Disabled', soundDisabled: 'Disabled',
testWarning: 'Warning',
testTimeUp: 'Time Up',
testMeetingEnd: 'Meeting End',
customSound: 'custom',
defaultSound: 'default',
warningTime: 'Warning before', warningTime: 'Warning before',
seconds: 'sec', seconds: 'sec',
defaultSpeakerTime: 'Default Speaker Time', defaultSpeakerTime: 'Default Speaker Time',
@@ -257,6 +286,25 @@ export const translations = {
windowFullHeight: 'Full screen height window', 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 // Participant management
participants: { participants: {
title: 'Manage Participants', title: 'Manage Participants',

View File

@@ -0,0 +1,56 @@
import { writable, get } from 'svelte/store';
function createAttendanceStore() {
const { subscribe, set, update } = writable({});
return {
subscribe,
// Initialize attendance for all participants (default: true)
init(participants) {
const current = get({ subscribe });
const newAttendance = {};
for (const p of participants) {
// Keep existing value or default to true
newAttendance[p.id] = current[p.id] !== undefined ? current[p.id] : true;
}
set(newAttendance);
},
// Toggle attendance for a participant
toggle(id) {
update((att) => {
att[id] = !att[id];
return { ...att };
});
},
// Set attendance for a participant
set(id, present) {
update((att) => {
att[id] = present;
return { ...att };
});
},
// Get current attendance object
get() {
return get({ subscribe });
},
// Reset all to true
resetAll() {
update((att) => {
const reset = {};
for (const id in att) {
reset[id] = true;
}
return reset;
});
},
};
}
export const attendance = createAttendanceStore();

View File

@@ -1,19 +1,28 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {models} from '../models'; import {models} from '../models';
import {updater} from '../models';
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>; 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 DeleteAllSessions():Promise<void>;
export function DeleteParticipant(arg1:number):Promise<void>; export function DeleteParticipant(arg1:number):Promise<void>;
export function DeleteSession(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 ExportCSV(arg1:string,arg2:string):Promise<string>;
export function ExportData(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 GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>; 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 GetTimerState():Promise<models.TimerState>;
export function GetVersion():Promise<string>;
export function NextSpeaker():Promise<void>; export function NextSpeaker():Promise<void>;
export function PauseMeeting():Promise<void>; export function PauseMeeting():Promise<void>;
@@ -38,14 +49,22 @@ export function RemoveFromQueue(arg1:number):Promise<void>;
export function ReorderParticipants(arg1:Array<number>):Promise<void>; export function ReorderParticipants(arg1:Array<number>):Promise<void>;
export function RestartApp():Promise<void>;
export function ResumeMeeting():Promise<void>; export function ResumeMeeting():Promise<void>;
export function SaveWindowPosition():Promise<void>;
export function SelectCustomSound(arg1:string):Promise<string>;
export function SkipSpeaker():Promise<void>; export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>; export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():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 UpdateMeeting(arg1:string,arg2:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4: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); 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() { export function DeleteAllSessions() {
return window['go']['app']['App']['DeleteAllSessions'](); return window['go']['app']['App']['DeleteAllSessions']();
} }
@@ -18,6 +26,10 @@ export function DeleteSession(arg1) {
return window['go']['app']['App']['DeleteSession'](arg1); return window['go']['app']['App']['DeleteSession'](arg1);
} }
export function DownloadAndInstallUpdate() {
return window['go']['app']['App']['DownloadAndInstallUpdate']();
}
export function ExportCSV(arg1, arg2) { export function ExportCSV(arg1, arg2) {
return window['go']['app']['App']['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); return window['go']['app']['App']['ExportData'](arg1, arg2);
} }
export function GetCustomSoundPath(arg1) {
return window['go']['app']['App']['GetCustomSoundPath'](arg1);
}
export function GetMeeting() { export function GetMeeting() {
return window['go']['app']['App']['GetMeeting'](); return window['go']['app']['App']['GetMeeting']();
} }
@@ -58,6 +74,10 @@ export function GetTimerState() {
return window['go']['app']['App']['GetTimerState'](); return window['go']['app']['App']['GetTimerState']();
} }
export function GetVersion() {
return window['go']['app']['App']['GetVersion']();
}
export function NextSpeaker() { export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker'](); return window['go']['app']['App']['NextSpeaker']();
} }
@@ -74,10 +94,22 @@ export function ReorderParticipants(arg1) {
return window['go']['app']['App']['ReorderParticipants'](arg1); return window['go']['app']['App']['ReorderParticipants'](arg1);
} }
export function RestartApp() {
return window['go']['app']['App']['RestartApp']();
}
export function ResumeMeeting() { export function ResumeMeeting() {
return window['go']['app']['App']['ResumeMeeting'](); return window['go']['app']['App']['ResumeMeeting']();
} }
export function SaveWindowPosition() {
return window['go']['app']['App']['SaveWindowPosition']();
}
export function SelectCustomSound(arg1) {
return window['go']['app']['App']['SelectCustomSound'](arg1);
}
export function SkipSpeaker() { export function SkipSpeaker() {
return window['go']['app']['App']['SkipSpeaker'](); return window['go']['app']['App']['SkipSpeaker']();
} }
@@ -90,6 +122,10 @@ export function StopMeeting() {
return window['go']['app']['App']['StopMeeting'](); return window['go']['app']['App']['StopMeeting']();
} }
export function SwitchToSpeaker(arg1) {
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
}
export function UpdateMeeting(arg1, arg2) { export function UpdateMeeting(arg1, arg2) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2); return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
} }

View File

@@ -327,6 +327,8 @@ export namespace models {
theme: string; theme: string;
windowWidth: number; windowWidth: number;
windowFullHeight: boolean; windowFullHeight: boolean;
windowX: number;
windowY: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Settings(source); return new Settings(source);
@@ -345,12 +347,15 @@ export namespace models {
this.theme = source["theme"]; this.theme = source["theme"];
this.windowWidth = source["windowWidth"]; this.windowWidth = source["windowWidth"];
this.windowFullHeight = source["windowFullHeight"]; this.windowFullHeight = source["windowFullHeight"];
this.windowX = source["windowX"];
this.windowY = source["windowY"];
} }
} }
export class SpeakerInfo { export class SpeakerInfo {
id: number; id: number;
name: string; name: string;
timeLimit: number; timeLimit: number;
timeSpent: number;
order: number; order: number;
status: string; status: string;
@@ -363,6 +368,7 @@ export namespace models {
this.id = source["id"]; this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.timeLimit = source["timeLimit"]; this.timeLimit = source["timeLimit"];
this.timeSpent = source["timeSpent"];
this.order = source["order"]; this.order = source["order"];
this.status = source["status"]; this.status = source["status"];
} }
@@ -432,3 +438,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" "time"
"daily-timer/internal/models" "daily-timer/internal/models"
"daily-timer/internal/services/updater"
"daily-timer/internal/storage" "daily-timer/internal/storage"
"daily-timer/internal/timer" "daily-timer/internal/timer"
"daily-timer/internal/version"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@@ -21,12 +23,14 @@ type App struct {
timer *timer.Timer timer *timer.Timer
session *models.MeetingSession session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog currentLogs map[uint]*models.ParticipantLog
updater *updater.Updater
} }
func New(store *storage.Storage) *App { func New(store *storage.Storage) *App {
return &App{ return &App{
store: store, store: store,
currentLogs: make(map[uint]*models.ParticipantLog), 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) { 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) runtime.WindowShow(ctx)
} }
func (a *App) Shutdown(ctx context.Context) { func (a *App) Shutdown(ctx context.Context) {
// Save window position before closing
a.saveWindowPosition()
if a.timer != nil { if a.timer != nil {
a.timer.Close() 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 // Participants
func (a *App) GetParticipants() ([]models.Participant, error) { func (a *App) GetParticipants() ([]models.Participant, error) {
@@ -175,13 +207,37 @@ func (a *App) handleTimerEvents() {
a.saveSpeakerLog(event.State) a.saveSpeakerLog(event.State)
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State) runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
case timer.EventMeetingEnded: case timer.EventMeetingEnded:
a.saveSpeakerLog(event.State) a.finalizeSpeakerLogs(event.State)
a.endMeetingSession(event.State) a.endMeetingSession(event.State)
runtime.EventsEmit(a.ctx, "timer:meeting_ended", 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) { func (a *App) saveSpeakerLog(state models.TimerState) {
if a.session == nil { if a.session == nil {
return 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() { func (a *App) PauseMeeting() {
if a.timer != nil { if a.timer != nil {
a.timer.Pause() a.timer.Pause()
@@ -479,3 +541,130 @@ func (a *App) GetSoundsDir() string {
_ = os.MkdirAll(soundsDir, 0755) _ = os.MkdirAll(soundsDir, 0755)
return soundsDir 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

@@ -69,4 +69,6 @@ type Settings struct {
Theme string `json:"theme" gorm:"default:dark"` 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 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"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
TimeLimit int `json:"timeLimit"` TimeLimit int `json:"timeLimit"`
TimeSpent int `json:"timeSpent"`
Order int `json:"order"` Order int `json:"order"`
Status SpeakerStatus `json:"status"` 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

@@ -105,25 +105,32 @@ func (t *Timer) Start() {
t.speakerWarned = false t.speakerWarned = false
t.meetingWarned = false t.meetingWarned = false
if len(t.queue) > 0 { hasSpeakers := len(t.queue) > 0
t.startNextSpeaker(now) if hasSpeakers {
t.startNextSpeaker(now, 0)
} }
t.mu.Unlock() t.mu.Unlock()
if hasSpeakers {
t.emit(EventSpeakerChanged)
}
go t.tick() 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 { if len(t.queue) == 0 {
return 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 { if t.currentSpeakerID != 0 {
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers { for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID { if t.allSpeakers[i].ID == t.currentSpeakerID {
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking { if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
t.allSpeakers[i].Status = models.SpeakerStatusDone t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
} }
break break
} }
@@ -135,8 +142,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
t.currentSpeakerID = speaker.ID t.currentSpeakerID = speaker.ID
t.currentSpeaker = speaker.Name t.currentSpeaker = speaker.Name
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
t.speakerStartTime = now t.speakerStartTime = now.Add(-offset)
t.speakerElapsed = 0 t.speakerElapsed = offset
t.speakingOrder++ t.speakingOrder++
t.speakerWarned = false t.speakerWarned = false
t.speakerTimeUpEmitted = false t.speakerTimeUpEmitted = false
@@ -191,7 +198,7 @@ func (t *Timer) NextSpeaker() {
var eventType EventType var eventType EventType
if len(t.queue) > 0 { if len(t.queue) > 0 {
t.startNextSpeaker(now) t.startNextSpeaker(now, 0)
eventType = EventSpeakerChanged eventType = EventSpeakerChanged
} else { } else {
t.running = false t.running = false
@@ -225,11 +232,14 @@ func (t *Timer) SkipSpeaker() {
now := time.Now() now := time.Now()
if len(t.queue) > 1 { if len(t.queue) > 1 {
t.startNextSpeaker(now) t.startNextSpeaker(now, 0)
t.mu.Unlock() t.mu.Unlock()
t.emit(EventSpeakerChanged) t.emit(EventSpeakerChanged)
} else { } else {
// Only skipped speaker left - they need to speak now
t.startNextSpeaker(now, 0)
t.mu.Unlock() t.mu.Unlock()
t.emit(EventSpeakerChanged)
} }
} }
@@ -246,7 +256,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
return 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 { for i, s := range t.queue {
if s.ID == speakerID { if s.ID == speakerID {
t.queue = append(t.queue[:i], t.queue[i+1:]...) t.queue = append(t.queue[:i], t.queue[i+1:]...)
@@ -254,11 +273,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 // Mark as skipped in allSpeakers and move to end
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped) t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
t.moveSpeakerToEnd(speakerID) 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() { func (t *Timer) Pause() {
t.mu.Lock() t.mu.Lock()
@@ -299,9 +431,17 @@ func (t *Timer) Stop() {
t.mu.Unlock() t.mu.Unlock()
return return
} }
// Mark current speaker as done before stopping // Mark current speaker as done and save their time spent
if t.currentSpeakerID != 0 { 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.running = false
t.paused = 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": { "info": {
"companyName": "Movida.Biz", "companyName": "Movida.Biz",
"productName": "Daily Timer", "productName": "Daily Timer",
"productVersion": "1.0.0", "productVersion": "0.1.0",
"comments": "Meeting timer with participant time tracking", "comments": "Meeting timer with participant time tracking",
"copyright": "Copyright © 2026 Movida.Biz" "copyright": "Copyright © 2026 Movida.Biz"
} }