Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
### Added
- new icon styles preference for tray (showing time to break or visual progress to break)
- Autostart functionality in Flatpaks

### Fixed
- snap package not starting on Wayland
Expand Down
15 changes: 12 additions & 3 deletions app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ app.on('before-quit', (event) => {
event.preventDefault()
} else {
globalShortcut.unregisterAll()
// Clean up D-Bus connections
if (autostartManager) {
autostartManager.disconnect()
}
app.quit()
}
})
Expand Down Expand Up @@ -331,9 +335,8 @@ async function initialize (isAppStart = true) {
}

autostartManager = new AutostartManager({
platform: process.platform,
windowsStore: insideWindowsStore(),
app
app,
settings
})

const imagesDir = join(app.getPath('userData'), 'images')
Expand All @@ -344,6 +347,12 @@ async function initialize (isAppStart = true) {
log.error('Stretchly: error creating images directory', error)
}
}
// Initialize portal early for Flatpak so it's ready when user opens preferences
if (insideFlatpak()) {
autostartManager.flatpakPortalManager.initialize().catch(err => {
log.error('Stretchly: Failed to initialize portal manager during startup:', err)
})
}

displayManager = new DisplayManager(settings)

Expand Down
69 changes: 39 additions & 30 deletions app/utils/autostartManager.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,61 @@
import log from 'electron-log/main.js'
import AutoLaunch from 'auto-launch'
import FlatpakPortalManager from './flatpakPortalManager.js'
import { insideFlatpak, insideWindowsStore } from './utils.js'

class AutostartManager {
constructor ({
platform,
windowsStore,
app
app,
settings
}) {
this.platform = platform
this.windowsStore = windowsStore
this.app = app

this.isFlatpak = insideFlatpak()
this.isWindowsStore = insideWindowsStore()

if (this.isFlatpak) {
this.flatpakPortalManager = new FlatpakPortalManager(settings)
} else if (process.platform === 'linux') {
this.nativeAutoLauncher = new AutoLaunch({ name: 'stretchly' })
} else if (this.isWindowsStore) {
this.windowsStoreAutoLauncher = new AutoLaunch({
name: 'Stretchly',
path: '33881JanHovancik.stretchly_24fg4m0zq65je!Stretchly',
isHidden: true
})
}
}

setAutostartEnabled (value) {
if (this.platform === 'linux') {
value ? this._linuxAutoLaunch.enable() : this._linuxAutoLaunch.disable()
} else if (this.platform === 'win32' && this.windowsStore) {
value ? this._windowsStoreAutoLaunch.enable() : this._windowsStoreAutoLaunch.disable()
async setAutostartEnabled (value) {
log.info(`Stretchly: setting autostart to ${value} on ${process.platform}${this.isWindowsStore ? ' (Windows Store)' : ''}${this.isFlatpak ? ' (Flatpak)' : ''}`)

if (this.isFlatpak) {
await (value ? this.flatpakPortalManager.enableAutostart() : this.flatpakPortalManager.disableAutostart())
} else if (process.platform === 'linux') {
await (value ? this.nativeAutoLauncher.enable() : this.nativeAutoLauncher.disable())
} else if (this.isWindowsStore) {
await (value ? this.windowsStoreAutoLauncher.enable() : this.windowsStoreAutoLauncher.disable())
} else {
this.app.setLoginItemSettings({ openAtLogin: value })
}
log.info(`Stretchly: setting autostart to ${value} on ${this.platform}${this.platform === 'win32' && this.windowsStore ? ' (Windows Store)' : ''}`)
}

async autoLaunchStatus () {
if (this.platform === 'linux') {
return await this._linuxAutoLaunch.isEnabled()
} else if (this.platform === 'win32' && this.windowsStore) {
return await this._windowsStoreAutoLaunch.isEnabled()
if (this.isFlatpak) {
return await this.flatpakPortalManager.isAutostartEnabled()
} else if (process.platform === 'linux') {
return await this.nativeAutoLauncher.isEnabled()
} else if (this.isWindowsStore) {
return await this.windowsStoreAutoLauncher.isEnabled()
} else {
return await this.app.getLoginItemSettings().openAtLogin
return this.app.getLoginItemSettings().openAtLogin
}
}

get _linuxAutoLaunch () {
const stretchlyAutoLaunch = new AutoLaunch({
name: 'stretchly'
})
return stretchlyAutoLaunch
}

get _windowsStoreAutoLaunch () {
const stretchlyAutoLaunch = new AutoLaunch({
name: 'Stretchly',
path: '33881JanHovancik.stretchly_24fg4m0zq65je!Stretchly',
isHidden: true
})
return stretchlyAutoLaunch
disconnect () {
if (this.flatpakPortalManager) {
this.flatpakPortalManager.disconnect()
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion app/utils/defaultSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,6 @@ export default {
hidePreferencesFileLocation: false,
hideStrictModePreferences: false,
miniBreakManualFinish: false,
longBreakManualFinish: false
longBreakManualFinish: false,
flatpakAutostart: false
}
194 changes: 194 additions & 0 deletions app/utils/flatpakPortalManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import dbus from '@particle/dbus-next'
import log from 'electron-log/main.js'

const { Variant } = dbus

class FlatpakPortalManager {
constructor (settings) {
this.settings = settings
this.bus = null
this.portal = null
this.initialized = false
this.portalRequestTimeoutMs = 30000
}

async initialize () {
if (this.initialized) return

if (this.bus) {
try {
this.bus.disconnect()
} catch {
// Ignore disconnect errors
}
this.bus = null
this.portal = null
}

try {
this.bus = dbus.sessionBus()
this.portal = await this.bus.getProxyObject(
'org.freedesktop.portal.Desktop',
'/org/freedesktop/portal/desktop'
)
this.initialized = true
log.info('Stretchly: XDG Background Portal initialized successfully')
} catch (error) {
log.error('Stretchly: Failed to initialize XDG Background Portal:', error)
this.initialized = false
}
}

/**
* Sets the autostart status for the application using the XDG Background Portal.
* @param {boolean} enabled - True to enable autostart, false to disable.
* @returns {Promise<boolean>} - True if the autostart status was successfully set.
*/
async setAutostart (enabled) {
await this.initialize()

if (!this.initialized) {
log.error('Stretchly: Cannot set autostart - portal not initialized')
return false
}

try {
const background = this.portal.getInterface('org.freedesktop.portal.Background')
const handleToken = `stretchly_autostart_${Date.now()}_${Math.random().toString(36).substring(7)}`

const options = {
handle_token: new Variant('s', handleToken),
reason: new Variant('s', 'Stretchly needs to run in the background to remind you to take breaks'),
autostart: new Variant('b', enabled),
'dbus-activatable': new Variant('b', false)
}

// When disabling autostart, the portal doesn't create a persistent request object.
// No Response signal is emitted, so we can return immediately.
if (!enabled) {
try {
await background.RequestBackground('', options)
log.info('Stretchly: Autostart disabled via XDG Portal')
return true
} catch (error) {
log.error('Stretchly: Failed to disable autostart via XDG Portal:', error)
return false
}
}

// When enabling autostart, we must wait for the Response signal.
// We start listening BEFORE calling the method to avoid race conditions.
const responsePromise = this._waitForBusResponse(handleToken, enabled)

const requestPath = await background.RequestBackground('', options)
log.info(`Stretchly: RequestBackground called, request path: ${requestPath}`)

return await responsePromise
} catch (error) {
log.error(`Stretchly: Failed to set autostart=${enabled} via XDG Portal:`, error)
return false
}
}

/**
* Waits for the Portal Request Response signal.
* @private
*/
_waitForBusResponse (handleToken, expectingEnabled) {
return new Promise((resolve) => {
let timeoutId = null
let messageListener = null

const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
if (messageListener && this.bus) {
this.bus.off('message', messageListener)
messageListener = null
}
}

messageListener = (msg) => {
if (msg.interface === 'org.freedesktop.portal.Request' &&
msg.member === 'Response' &&
msg.path.endsWith(handleToken)) {
const [response, results] = msg.body

cleanup()

log.info(`Stretchly: Portal Response signal received - response: ${response}, results:`, results)

// Response codes: 0 = success, 1 = user cancelled, 2 = other error
if (response === 0) {
const autostartGranted = results && results.autostart && results.autostart.value === expectingEnabled
if (autostartGranted) {
log.info('Stretchly: Autostart enabled via XDG Portal')
} else {
log.warn('Stretchly: Autostart status did not match request', results)
}
resolve(autostartGranted)
} else if (response === 1) {
log.warn('Stretchly: User cancelled the portal request')
resolve(false)
} else {
log.error(`Stretchly: Portal request failed with response code: ${response}`)
resolve(false)
}
}
}

timeoutId = setTimeout(() => {
cleanup()
log.error(`Stretchly: Portal request timeout after ${this.portalRequestTimeoutMs / 1000} seconds`)
resolve(false)
}, this.portalRequestTimeoutMs)

this.bus.on('message', messageListener)
})
}

async enableAutostart () {
try {
const success = await this.setAutostart(true)
if (success) {
this.settings.set('flatpakAutostart', true)
}
return success
} catch (error) {
log.error('Stretchly: Failed to set autostart (enable) via XDG Portal', error)
return false
}
}

async disableAutostart () {
try {
const success = await this.setAutostart(false)
if (success) {
this.settings.set('flatpakAutostart', false)
}
return success
} catch (error) {
log.error('Stretchly: Failed to set autostart (disable) via XDG Portal', error)
return false
}
}

async isAutostartEnabled () {
// XDG portals don't provide a reliable query method, so we read from our cache
return this.settings.get('flatpakAutostart')
}

disconnect () {
if (this.bus) {
this.bus.disconnect()
this.bus = null
this.portal = null
this.initialized = false
log.info('Stretchly: XDG Background Portal disconnected')
}
}
}

export default FlatpakPortalManager
5 changes: 2 additions & 3 deletions app/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,15 @@ function shouldShowNotificationTitle (platform, systemVersion, semver) {
}

function insideFlatpak () {
const flatpakInfoPath = '/.flatpak-info'
return fs.existsSync(flatpakInfoPath)
return process.platform === 'linux' && fs.existsSync('/.flatpak-info')
}

function insideWindowsStore () {
return process.platform === 'win32' && !!process.windowsStore
}

function insideSnap () {
return !!process.env.SNAP
return process.platform === 'linux' && !!process.env.SNAP
}

export {
Expand Down