linux appimage build with docker, egl fallback, and webkitgtk fixes

This commit is contained in:
Your Name
2026-02-27 13:25:53 +02:00
parent c0405ceda3
commit 9ab3536942
19 changed files with 1260 additions and 86 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { ref, onMounted } from 'vue'
import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects'
@@ -35,7 +36,11 @@ async function toggleMaximize() {
}
async function close() {
await appWindow.close()
if (settingsStore.settings.minimize_to_tray === 'true') {
await appWindow.hide()
} else {
await invoke('quit_app')
}
}
async function startDrag() {

View File

@@ -1,5 +1,5 @@
import type { Directive, DirectiveBinding } from 'vue'
import { getZoomFactor } from '../utils/dropdown'
import { getFixedPositionMapping } from '../utils/dropdown'
interface TooltipState {
el: HTMLElement
@@ -48,32 +48,29 @@ function positionTooltip(state: TooltipState) {
if (!tip) return
const rect = state.el.getBoundingClientRect()
const zoom = getZoomFactor()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const margin = 6
const arrowSize = 4
// Measure tooltip
// Measure tooltip (in viewport pixels)
tip.style.left = '-9999px'
tip.style.top = '-9999px'
tip.style.opacity = '0'
const tipRect = tip.getBoundingClientRect()
const tipW = tipRect.width / zoom
const tipH = tipRect.height / zoom
const tipW = tipRect.width
const tipH = tipRect.height
const elTop = rect.top / zoom
const elLeft = rect.left / zoom
const elW = rect.width / zoom
const elH = rect.height / zoom
const vpW = window.innerWidth / zoom
const vpH = window.innerHeight / zoom
// All in viewport pixels for placement decisions
const vpW = window.innerWidth
const vpH = window.innerHeight
// Determine placement
let placement = state.placement
if (placement === 'auto') {
const spaceAbove = elTop
const spaceBelow = vpH - elTop - elH
const spaceRight = vpW - elLeft - elW
const spaceLeft = elLeft
const spaceAbove = rect.top
const spaceBelow = vpH - rect.bottom
const spaceRight = vpW - rect.right
const spaceLeft = rect.left
// Prefer top, then bottom, then right, then left
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
@@ -83,8 +80,9 @@ function positionTooltip(state: TooltipState) {
else placement = 'top'
}
let top = 0
let left = 0
// Calculate position in viewport pixels
let topVP = 0
let leftVP = 0
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
// Reset arrow classes
@@ -92,37 +90,39 @@ function positionTooltip(state: TooltipState) {
switch (placement) {
case 'top':
top = elTop - tipH - margin
left = elLeft + elW / 2 - tipW / 2
topVP = rect.top - tipH - margin
leftVP = rect.left + rect.width / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
break
case 'bottom':
top = elTop + elH + margin
left = elLeft + elW / 2 - tipW / 2
topVP = rect.bottom + margin
leftVP = rect.left + rect.width / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
break
case 'right':
top = elTop + elH / 2 - tipH / 2
left = elLeft + elW + margin
topVP = rect.top + rect.height / 2 - tipH / 2
leftVP = rect.right + margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
break
case 'left':
top = elTop + elH / 2 - tipH / 2
left = elLeft - tipW - margin
topVP = rect.top + rect.height / 2 - tipH / 2
leftVP = rect.left - tipW - margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
break
}
// Clamp to viewport
left = Math.max(4, Math.min(left, vpW - tipW - 4))
top = Math.max(4, Math.min(top, vpH - tipH - 4))
leftVP = Math.max(4, Math.min(leftVP, vpW - tipW - 4))
topVP = Math.max(4, Math.min(topVP, vpH - tipH - 4))
tip.style.left = `${left}px`
tip.style.top = `${top}px`
// Convert viewport pixels to CSS pixels for position:fixed inside #app
// (handles coordinate mapping differences between Chromium and WebKitGTK)
tip.style.left = `${(leftVP - offsetX) / scaleX}px`
tip.style.top = `${(topVP - offsetY) / scaleY}px`
tip.style.opacity = '1'
}

View File

@@ -111,6 +111,8 @@
height: 100%;
width: 100%;
overflow: auto;
background-color: var(--color-bg-base);
overflow: hidden;
}
body {

View File

@@ -1,3 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
export type SoundEvent =
| 'timer_start'
| 'timer_stop'
@@ -41,9 +43,59 @@ const DEFAULT_SETTINGS: AudioSettings = {
events: { ...DEFAULT_EVENTS },
}
// Tone description for the Rust backend
interface SoundTone {
freq: number
duration_ms: number
delay_ms: number
freq_end?: number
detune?: number
}
// Map each sound event to its tone sequence (mirrors the Web Audio synthesis)
const TONE_MAP: Record<SoundEvent, SoundTone[]> = {
timer_start: [
{ freq: 523, duration_ms: 100, delay_ms: 0, detune: 3 },
{ freq: 659, duration_ms: 150, delay_ms: 10, detune: 3 },
],
timer_stop: [
{ freq: 784, duration_ms: 250, delay_ms: 0, freq_end: 523 },
],
timer_pause: [
{ freq: 440, duration_ms: 120, delay_ms: 0 },
],
timer_resume: [
{ freq: 523, duration_ms: 120, delay_ms: 0 },
],
idle_alert: [
{ freq: 880, duration_ms: 80, delay_ms: 0 },
{ freq: 880, duration_ms: 80, delay_ms: 60 },
],
goal_reached: [
{ freq: 523, duration_ms: 120, delay_ms: 0, detune: 3 },
{ freq: 659, duration_ms: 120, delay_ms: 10, detune: 3 },
{ freq: 784, duration_ms: 120, delay_ms: 10, detune: 3 },
],
break_reminder: [
{ freq: 659, duration_ms: 200, delay_ms: 0 },
],
}
class AudioEngine {
private ctx: AudioContext | null = null
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
private _isLinux: boolean | null = null
private async isLinux(): Promise<boolean> {
if (this._isLinux === null) {
try {
this._isLinux = (await invoke('get_platform')) === 'linux'
} catch {
this._isLinux = false
}
}
return this._isLinux
}
private ensureContext(): AudioContext {
if (!this.ctx) {
@@ -81,7 +133,17 @@ class AudioEngine {
this.synthesize(event)
}
private synthesize(event: SoundEvent) {
private async synthesize(event: SoundEvent) {
// On Linux, use the Rust backend which plays via paplay/pw-play/aplay
if (await this.isLinux()) {
const tones = TONE_MAP[event]
if (tones) {
invoke('play_sound', { tones, volume: this.gain }).catch(() => {})
}
return
}
// On other platforms, use Web Audio API
switch (event) {
case 'timer_start':
this.playTimerStart()

View File

@@ -526,6 +526,11 @@
</div>
</div>
<!-- Linux app tracking notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
On Linux, window visibility cannot be detected. The timer will only pause when the tracked app's process exits entirely.
</p>
<!-- Check Interval -->
<div class="flex items-center justify-between">
<div>
@@ -547,7 +552,7 @@
<div class="border-t border-border-subtle" />
<!-- Timeline Recording -->
<div class="flex items-center justify-between">
<div :class="['flex items-center justify-between', platform === 'linux' && 'opacity-50 pointer-events-none']">
<div>
<p class="text-[0.8125rem] text-text-primary">Record app timeline</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p>
@@ -556,6 +561,7 @@
@click="toggleTimelineRecording"
role="switch"
:aria-checked="timelineRecording"
:disabled="platform === 'linux'"
aria-label="Record app timeline"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
@@ -570,6 +576,10 @@
/>
</button>
</div>
<!-- Linux timeline notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
Timeline recording is not available on Linux - Wayland's security model prevents detecting the foreground window.
</p>
<!-- Divider -->
<div class="border-t border-border-subtle" />
@@ -1674,6 +1684,7 @@ const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
const timelineRecording = ref(false)
const platform = ref('')
const timerFont = ref('JetBrains Mono')
const timerFontOptions = TIMER_FONTS
const reduceMotion = ref('system')
@@ -2495,6 +2506,7 @@ async function clearAllData() {
// Load settings on mount
onMounted(async () => {
try { platform.value = await invoke('get_platform') } catch { /* non-critical */ }
await settingsStore.fetchSettings()
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
@@ -2524,7 +2536,7 @@ onMounted(async () => {
businessEmail.value = settingsStore.settings.business_email || ''
businessPhone.value = settingsStore.settings.business_phone || ''
businessLogo.value = settingsStore.settings.business_logo || ''
timelineRecording.value = settingsStore.settings.timeline_recording === 'on'
timelineRecording.value = platform.value !== 'linux' && settingsStore.settings.timeline_recording === 'on'
timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono'
reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'