Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75dc03b0fd | ||
|
|
a81540646e |
25
Makefile
25
Makefile
@@ -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,18 @@ 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"
|
||||||
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
|
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
|
||||||
@ls -lh dist/*.zip
|
@ls -lh dist/*.zip
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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 } 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,15 @@
|
|||||||
let windowFullHeight = true
|
let windowFullHeight = true
|
||||||
let audioContext = null
|
let audioContext = null
|
||||||
|
|
||||||
|
// 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)()
|
||||||
@@ -67,7 +76,62 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadData()
|
await loadData()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
EventsOff('update:progress')
|
||||||
|
EventsOff('update:complete')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
@@ -204,6 +268,66 @@
|
|||||||
</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">
|
||||||
|
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
||||||
|
</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>
|
||||||
@@ -369,4 +493,126 @@
|
|||||||
.test-btn:active {
|
.test-btn:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ export const translations = {
|
|||||||
windowFullHeight: 'Окно на всю высоту экрана',
|
windowFullHeight: 'Окно на всю высоту экрана',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
updates: {
|
||||||
|
title: 'Обновления',
|
||||||
|
currentVersion: 'Текущая версия',
|
||||||
|
checkingForUpdates: 'Проверка обновлений...',
|
||||||
|
updateAvailable: 'Доступно обновление',
|
||||||
|
upToDate: 'У вас последняя версия',
|
||||||
|
downloadAndInstall: 'Скачать и установить',
|
||||||
|
downloading: 'Загрузка...',
|
||||||
|
installing: 'Установка...',
|
||||||
|
restartRequired: 'Для завершения обновления требуется перезапуск',
|
||||||
|
restart: 'Перезапустить',
|
||||||
|
later: 'Позже',
|
||||||
|
error: 'Ошибка проверки обновлений',
|
||||||
|
downloadError: 'Ошибка загрузки обновления',
|
||||||
|
checkNow: 'Проверить сейчас',
|
||||||
|
},
|
||||||
|
|
||||||
// Participant management
|
// Participant management
|
||||||
participants: {
|
participants: {
|
||||||
title: 'Управление участниками',
|
title: 'Управление участниками',
|
||||||
@@ -257,6 +275,24 @@ 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',
|
||||||
|
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',
|
||||||
|
|||||||
9
frontend/wailsjs/go/app/App.d.ts
vendored
9
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,15 +1,20 @@
|
|||||||
// 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 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>;
|
||||||
@@ -30,6 +35,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,6 +45,8 @@ 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 SkipSpeaker():Promise<void>;
|
export function SkipSpeaker():Promise<void>;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ 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 DeleteAllSessions() {
|
export function DeleteAllSessions() {
|
||||||
return window['go']['app']['App']['DeleteAllSessions']();
|
return window['go']['app']['App']['DeleteAllSessions']();
|
||||||
}
|
}
|
||||||
@@ -18,6 +22,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);
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,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,6 +86,10 @@ 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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,3 +432,30 @@ export namespace models {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace updater {
|
||||||
|
|
||||||
|
export class UpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
releaseNotes: string;
|
||||||
|
downloadURL: string;
|
||||||
|
downloadSize: number;
|
||||||
|
|
||||||
|
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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,3 +483,28 @@ 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()
|
||||||
|
}
|
||||||
|
|||||||
338
internal/services/updater/updater.go
Normal file
338
internal/services/updater/updater.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
latestVersion := strings.TrimPrefix(release.TagName, "v")
|
||||||
|
currentVersion := strings.TrimPrefix(version.Version, "v")
|
||||||
|
|
||||||
|
info := &UpdateInfo{
|
||||||
|
Available: isNewerVersion(latestVersion, currentVersion),
|
||||||
|
CurrentVersion: version.Version,
|
||||||
|
LatestVersion: release.TagName,
|
||||||
|
ReleaseNotes: release.Body,
|
||||||
|
DownloadURL: downloadAsset.BrowserDownloadURL,
|
||||||
|
DownloadSize: downloadAsset.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Launch new app
|
||||||
|
cmd := exec.Command("open", destPath)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to launch updated app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
10
internal/version/version.go
Normal file
10
internal/version/version.go
Normal 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"
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user