linux appimage build with docker, egl fallback, and webkitgtk fixes
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--color-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user