2 Commits

Author SHA1 Message Date
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
10 changed files with 728 additions and 14 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,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

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 } 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,8 +76,63 @@
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
try { try {
@@ -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>

View File

@@ -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',

View File

@@ -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>;

View File

@@ -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']();
} }

View File

@@ -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"];
}
}
}

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(),
} }
} }
@@ -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()
}

View 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)
}

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"
} }