Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc93ebbd26 | ||
|
|
f0a8c32ea2 | ||
|
|
850d1deed2 | ||
|
|
5131a72983 | ||
|
|
6dac14e0c1 | ||
|
|
482786a34b | ||
|
|
906f504d49 | ||
|
|
649b1c039d | ||
|
|
5fd85bfc50 | ||
|
|
809f64b93d | ||
|
|
30af8729b8 | ||
|
|
cf0d60f40c | ||
|
|
9f5c9d568d | ||
|
|
2b86eb9d20 |
6
Makefile
6
Makefile
@@ -64,8 +64,10 @@ release: lint
|
|||||||
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
|
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
|
||||||
@rm -rf dist && 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: lint
|
release-all: lint
|
||||||
@@ -89,7 +91,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 \
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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
|
||||||
@@ -172,6 +191,7 @@ GITEA_TOKEN=<token> make release-publish
|
|||||||
- [ ] Шаблоны команд
|
- [ ] Шаблоны команд
|
||||||
- [ ] Облачная синхронизация
|
- [ ] Облачная синхронизация
|
||||||
- [ ] Поддержка Windows/Linux
|
- [ ] Поддержка Windows/Linux
|
||||||
|
- [x] Автообновление приложения
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,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 +101,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 +119,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 +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) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +359,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 {
|
||||||
|
|||||||
@@ -8,10 +8,17 @@
|
|||||||
|
|
||||||
$: allSpeakers = timerState?.allSpeakers || []
|
$: allSpeakers = timerState?.allSpeakers || []
|
||||||
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
||||||
|
$: currentElapsed = timerState?.speakerElapsed || 0
|
||||||
|
|
||||||
function handleSkip(speakerId) {
|
function handleSkip(speakerId) {
|
||||||
dispatch('skip', { speakerId })
|
dispatch('skip', { 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">
|
||||||
@@ -23,7 +30,23 @@
|
|||||||
<li class="speaker-item {speaker.status}">
|
<li class="speaker-item {speaker.status}">
|
||||||
<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 === '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 === 'skipped'}
|
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
||||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||||
⏭
|
⏭
|
||||||
@@ -129,13 +152,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 +179,29 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent.overtime {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-sep {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-limit {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, createEventDispatcher } from 'svelte'
|
import { onMount, createEventDispatcher } from 'svelte'
|
||||||
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp } 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, EventsOn, EventsOff } 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'
|
||||||
|
|
||||||
@@ -16,6 +16,14 @@
|
|||||||
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
|
// Update state
|
||||||
let currentVersion = 'dev'
|
let currentVersion = 'dev'
|
||||||
let updateInfo = null
|
let updateInfo = null
|
||||||
@@ -51,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)
|
||||||
@@ -74,8 +88,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Load version and check for updates
|
||||||
try {
|
try {
|
||||||
@@ -94,9 +163,29 @@
|
|||||||
updateComplete = true
|
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 () => {
|
return () => {
|
||||||
EventsOff('update:progress')
|
EventsOff('update:progress')
|
||||||
EventsOff('update:complete')
|
EventsOff('update:complete')
|
||||||
|
document.removeEventListener('click', warmUpAudio)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,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>
|
||||||
|
|
||||||
@@ -309,7 +448,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if updateInfo?.available}
|
{:else if updateInfo?.available}
|
||||||
<div class="update-status available">
|
<div class="update-status available">
|
||||||
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
{#if updateInfo.isRebuild}
|
||||||
|
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
||||||
|
{:else}
|
||||||
|
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button class="update-btn primary" on:click={downloadAndInstall}>
|
<button class="update-btn primary" on:click={downloadAndInstall}>
|
||||||
{$t('updates.downloadAndInstall')}
|
{$t('updates.downloadAndInstall')}
|
||||||
@@ -494,6 +637,84 @@
|
|||||||
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 */
|
||||||
.updates-section {
|
.updates-section {
|
||||||
border: 1px solid #3d4f61;
|
border: 1px solid #3d4f61;
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export const translations = {
|
|||||||
sound: 'Звуковые уведомления',
|
sound: 'Звуковые уведомления',
|
||||||
soundEnabled: 'Включены',
|
soundEnabled: 'Включены',
|
||||||
soundDisabled: 'Выключены',
|
soundDisabled: 'Выключены',
|
||||||
|
testWarning: 'Предупреждение',
|
||||||
|
testTimeUp: 'Время вышло',
|
||||||
|
testMeetingEnd: 'Конец собрания',
|
||||||
|
customSound: 'свой',
|
||||||
|
defaultSound: 'стандартный',
|
||||||
warningTime: 'Предупреждение за',
|
warningTime: 'Предупреждение за',
|
||||||
seconds: 'сек',
|
seconds: 'сек',
|
||||||
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
||||||
@@ -114,6 +119,7 @@ export const translations = {
|
|||||||
currentVersion: 'Текущая версия',
|
currentVersion: 'Текущая версия',
|
||||||
checkingForUpdates: 'Проверка обновлений...',
|
checkingForUpdates: 'Проверка обновлений...',
|
||||||
updateAvailable: 'Доступно обновление',
|
updateAvailable: 'Доступно обновление',
|
||||||
|
rebuildAvailable: 'Доступна пересборка',
|
||||||
upToDate: 'У вас последняя версия',
|
upToDate: 'У вас последняя версия',
|
||||||
downloadAndInstall: 'Скачать и установить',
|
downloadAndInstall: 'Скачать и установить',
|
||||||
downloading: 'Загрузка...',
|
downloading: 'Загрузка...',
|
||||||
@@ -261,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',
|
||||||
@@ -281,6 +292,7 @@ export const translations = {
|
|||||||
currentVersion: 'Current version',
|
currentVersion: 'Current version',
|
||||||
checkingForUpdates: 'Checking for updates...',
|
checkingForUpdates: 'Checking for updates...',
|
||||||
updateAvailable: 'Update available',
|
updateAvailable: 'Update available',
|
||||||
|
rebuildAvailable: 'Rebuild available',
|
||||||
upToDate: 'You have the latest version',
|
upToDate: 'You have the latest version',
|
||||||
downloadAndInstall: 'Download and install',
|
downloadAndInstall: 'Download and install',
|
||||||
downloading: 'Downloading...',
|
downloading: 'Downloading...',
|
||||||
|
|||||||
6
frontend/wailsjs/go/app/App.d.ts
vendored
6
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -7,6 +7,8 @@ export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<mode
|
|||||||
|
|
||||||
export function CheckForUpdates():Promise<updater.UpdateInfo>;
|
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>;
|
||||||
@@ -19,6 +21,8 @@ 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>>;
|
||||||
@@ -49,6 +53,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function ResumeMeeting():Promise<void>;
|
export function ResumeMeeting():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>;
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function CheckForUpdates() {
|
|||||||
return window['go']['app']['App']['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']();
|
||||||
}
|
}
|
||||||
@@ -34,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']();
|
||||||
}
|
}
|
||||||
@@ -94,6 +102,10 @@ export function ResumeMeeting() {
|
|||||||
return window['go']['app']['App']['ResumeMeeting']();
|
return window['go']['app']['App']['ResumeMeeting']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export namespace models {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
timeLimit: number;
|
timeLimit: number;
|
||||||
|
timeSpent: number;
|
||||||
order: number;
|
order: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@@ -363,6 +364,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"];
|
||||||
}
|
}
|
||||||
@@ -441,6 +443,7 @@ export namespace updater {
|
|||||||
releaseNotes: string;
|
releaseNotes: string;
|
||||||
downloadURL: string;
|
downloadURL: string;
|
||||||
downloadSize: number;
|
downloadSize: number;
|
||||||
|
isRebuild: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UpdateInfo(source);
|
return new UpdateInfo(source);
|
||||||
@@ -454,6 +457,7 @@ export namespace updater {
|
|||||||
this.releaseNotes = source["releaseNotes"];
|
this.releaseNotes = source["releaseNotes"];
|
||||||
this.downloadURL = source["downloadURL"];
|
this.downloadURL = source["downloadURL"];
|
||||||
this.downloadSize = source["downloadSize"];
|
this.downloadSize = source["downloadSize"];
|
||||||
|
this.isRebuild = source["isRebuild"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -508,3 +508,105 @@ func (a *App) DownloadAndInstallUpdate() error {
|
|||||||
func (a *App) RestartApp() error {
|
func (a *App) RestartApp() error {
|
||||||
return a.updater.RestartApp()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package updater
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -43,6 +45,7 @@ type UpdateInfo struct {
|
|||||||
ReleaseNotes string `json:"releaseNotes"`
|
ReleaseNotes string `json:"releaseNotes"`
|
||||||
DownloadURL string `json:"downloadURL"`
|
DownloadURL string `json:"downloadURL"`
|
||||||
DownloadSize int64 `json:"downloadSize"`
|
DownloadSize int64 `json:"downloadSize"`
|
||||||
|
IsRebuild bool `json:"isRebuild"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Updater struct {
|
type Updater struct {
|
||||||
@@ -89,16 +92,40 @@ func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
|
|||||||
|
|
||||||
u.downloadURL = downloadAsset.BrowserDownloadURL
|
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")
|
latestVersion := strings.TrimPrefix(release.TagName, "v")
|
||||||
currentVersion := strings.TrimPrefix(version.Version, "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{
|
info := &UpdateInfo{
|
||||||
Available: isNewerVersion(latestVersion, currentVersion),
|
Available: isNewer || isRebuild,
|
||||||
CurrentVersion: version.Version,
|
CurrentVersion: version.Version,
|
||||||
LatestVersion: release.TagName,
|
LatestVersion: release.TagName,
|
||||||
ReleaseNotes: release.Body,
|
ReleaseNotes: release.Body,
|
||||||
DownloadURL: downloadAsset.BrowserDownloadURL,
|
DownloadURL: downloadAsset.BrowserDownloadURL,
|
||||||
DownloadSize: downloadAsset.Size,
|
DownloadSize: downloadAsset.Size,
|
||||||
|
IsRebuild: isRebuild,
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
@@ -160,12 +187,27 @@ func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
|
|||||||
func (u *Updater) RestartApp() error {
|
func (u *Updater) RestartApp() error {
|
||||||
destPath := filepath.Join(InstallPath, AppName)
|
destPath := filepath.Join(InstallPath, AppName)
|
||||||
|
|
||||||
// Launch new app
|
// Use shell to launch new app after this process exits
|
||||||
cmd := exec.Command("open", destPath)
|
// 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 {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("failed to launch updated app: %w", err)
|
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
|
// Exit current app
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
return nil
|
return nil
|
||||||
@@ -304,6 +346,54 @@ func (u *Updater) copyDir(src, dst string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
// isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
|
||||||
func isNewerVersion(latest, current string) bool {
|
func isNewerVersion(latest, current string) bool {
|
||||||
if current == "dev" || current == "unknown" {
|
if current == "dev" || current == "unknown" {
|
||||||
|
|||||||
@@ -118,12 +118,14 @@ func (t *Timer) startNextSpeaker(now time.Time) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -299,9 +301,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
|
||||||
|
|||||||
Reference in New Issue
Block a user