Skip to content

Commit 4cd799d

Browse files
Add flatpak portal manager, autostart on flatpaks (#1670)
* Add flatpak portal manager, autostart on flatpaks Assisted-by: Claude Code, Sonnet 4.5 Signed-off-by: Andrew Bernal <[email protected]> * strategy pattern for linuxAutoLaunch, fix comments Signed-off-by: Andrew Bernal <[email protected]> * move flatpak portal management code entirely to it, other small fixes Signed-off-by: Andrew Bernal <[email protected]> * Copilot changes, refactor _waitForBusResponse - Add _waitForBusResponse to make it easier to read `setAutostart` - Copilot change for `portalRequestTimeoutMs` - Copilot change for `endsWith` handle token Signed-off-by: Andrew Bernal <[email protected]> * Refactor * Only update settings on successful operation * Fix smaller issues * Small improvements * Simplify the params --------- Signed-off-by: Andrew Bernal <[email protected]> Signed-off-by: Andrew Bernal <[email protected]> Co-authored-by: Jan Hovancik <[email protected]>
1 parent 697537c commit 4cd799d

File tree

6 files changed

+250
-37
lines changed

6 files changed

+250
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77
## [Unreleased]
88
### Added
99
- new icon styles preference for tray (showing time to break or visual progress to break)
10+
- Autostart functionality in Flatpaks
1011

1112
### Fixed
1213
- snap package not starting on Wayland

app/main.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ app.on('before-quit', (event) => {
194194
event.preventDefault()
195195
} else {
196196
globalShortcut.unregisterAll()
197+
// Clean up D-Bus connections
198+
if (autostartManager) {
199+
autostartManager.disconnect()
200+
}
197201
app.quit()
198202
}
199203
})
@@ -331,9 +335,8 @@ async function initialize (isAppStart = true) {
331335
}
332336

333337
autostartManager = new AutostartManager({
334-
platform: process.platform,
335-
windowsStore: insideWindowsStore(),
336-
app
338+
app,
339+
settings
337340
})
338341

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

348357
displayManager = new DisplayManager(settings)
349358

app/utils/autostartManager.js

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,61 @@
11
import log from 'electron-log/main.js'
22
import AutoLaunch from 'auto-launch'
3+
import FlatpakPortalManager from './flatpakPortalManager.js'
4+
import { insideFlatpak, insideWindowsStore } from './utils.js'
35

46
class AutostartManager {
57
constructor ({
6-
platform,
7-
windowsStore,
8-
app
8+
app,
9+
settings
910
}) {
10-
this.platform = platform
11-
this.windowsStore = windowsStore
1211
this.app = app
12+
13+
this.isFlatpak = insideFlatpak()
14+
this.isWindowsStore = insideWindowsStore()
15+
16+
if (this.isFlatpak) {
17+
this.flatpakPortalManager = new FlatpakPortalManager(settings)
18+
} else if (process.platform === 'linux') {
19+
this.nativeAutoLauncher = new AutoLaunch({ name: 'stretchly' })
20+
} else if (this.isWindowsStore) {
21+
this.windowsStoreAutoLauncher = new AutoLaunch({
22+
name: 'Stretchly',
23+
path: '33881JanHovancik.stretchly_24fg4m0zq65je!Stretchly',
24+
isHidden: true
25+
})
26+
}
1327
}
1428

15-
setAutostartEnabled (value) {
16-
if (this.platform === 'linux') {
17-
value ? this._linuxAutoLaunch.enable() : this._linuxAutoLaunch.disable()
18-
} else if (this.platform === 'win32' && this.windowsStore) {
19-
value ? this._windowsStoreAutoLaunch.enable() : this._windowsStoreAutoLaunch.disable()
29+
async setAutostartEnabled (value) {
30+
log.info(`Stretchly: setting autostart to ${value} on ${process.platform}${this.isWindowsStore ? ' (Windows Store)' : ''}${this.isFlatpak ? ' (Flatpak)' : ''}`)
31+
32+
if (this.isFlatpak) {
33+
await (value ? this.flatpakPortalManager.enableAutostart() : this.flatpakPortalManager.disableAutostart())
34+
} else if (process.platform === 'linux') {
35+
await (value ? this.nativeAutoLauncher.enable() : this.nativeAutoLauncher.disable())
36+
} else if (this.isWindowsStore) {
37+
await (value ? this.windowsStoreAutoLauncher.enable() : this.windowsStoreAutoLauncher.disable())
2038
} else {
2139
this.app.setLoginItemSettings({ openAtLogin: value })
2240
}
23-
log.info(`Stretchly: setting autostart to ${value} on ${this.platform}${this.platform === 'win32' && this.windowsStore ? ' (Windows Store)' : ''}`)
2441
}
2542

2643
async autoLaunchStatus () {
27-
if (this.platform === 'linux') {
28-
return await this._linuxAutoLaunch.isEnabled()
29-
} else if (this.platform === 'win32' && this.windowsStore) {
30-
return await this._windowsStoreAutoLaunch.isEnabled()
44+
if (this.isFlatpak) {
45+
return await this.flatpakPortalManager.isAutostartEnabled()
46+
} else if (process.platform === 'linux') {
47+
return await this.nativeAutoLauncher.isEnabled()
48+
} else if (this.isWindowsStore) {
49+
return await this.windowsStoreAutoLauncher.isEnabled()
3150
} else {
32-
return await this.app.getLoginItemSettings().openAtLogin
51+
return this.app.getLoginItemSettings().openAtLogin
3352
}
3453
}
3554

36-
get _linuxAutoLaunch () {
37-
const stretchlyAutoLaunch = new AutoLaunch({
38-
name: 'stretchly'
39-
})
40-
return stretchlyAutoLaunch
41-
}
42-
43-
get _windowsStoreAutoLaunch () {
44-
const stretchlyAutoLaunch = new AutoLaunch({
45-
name: 'Stretchly',
46-
path: '33881JanHovancik.stretchly_24fg4m0zq65je!Stretchly',
47-
isHidden: true
48-
})
49-
return stretchlyAutoLaunch
55+
disconnect () {
56+
if (this.flatpakPortalManager) {
57+
this.flatpakPortalManager.disconnect()
58+
}
5059
}
5160
}
5261

app/utils/defaultSettings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,6 @@ export default {
8080
hidePreferencesFileLocation: false,
8181
hideStrictModePreferences: false,
8282
miniBreakManualFinish: false,
83-
longBreakManualFinish: false
83+
longBreakManualFinish: false,
84+
flatpakAutostart: false
8485
}

app/utils/flatpakPortalManager.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import dbus from '@particle/dbus-next'
2+
import log from 'electron-log/main.js'
3+
4+
const { Variant } = dbus
5+
6+
class FlatpakPortalManager {
7+
constructor (settings) {
8+
this.settings = settings
9+
this.bus = null
10+
this.portal = null
11+
this.initialized = false
12+
this.portalRequestTimeoutMs = 30000
13+
}
14+
15+
async initialize () {
16+
if (this.initialized) return
17+
18+
if (this.bus) {
19+
try {
20+
this.bus.disconnect()
21+
} catch {
22+
// Ignore disconnect errors
23+
}
24+
this.bus = null
25+
this.portal = null
26+
}
27+
28+
try {
29+
this.bus = dbus.sessionBus()
30+
this.portal = await this.bus.getProxyObject(
31+
'org.freedesktop.portal.Desktop',
32+
'/org/freedesktop/portal/desktop'
33+
)
34+
this.initialized = true
35+
log.info('Stretchly: XDG Background Portal initialized successfully')
36+
} catch (error) {
37+
log.error('Stretchly: Failed to initialize XDG Background Portal:', error)
38+
this.initialized = false
39+
}
40+
}
41+
42+
/**
43+
* Sets the autostart status for the application using the XDG Background Portal.
44+
* @param {boolean} enabled - True to enable autostart, false to disable.
45+
* @returns {Promise<boolean>} - True if the autostart status was successfully set.
46+
*/
47+
async setAutostart (enabled) {
48+
await this.initialize()
49+
50+
if (!this.initialized) {
51+
log.error('Stretchly: Cannot set autostart - portal not initialized')
52+
return false
53+
}
54+
55+
try {
56+
const background = this.portal.getInterface('org.freedesktop.portal.Background')
57+
const handleToken = `stretchly_autostart_${Date.now()}_${Math.random().toString(36).substring(7)}`
58+
59+
const options = {
60+
handle_token: new Variant('s', handleToken),
61+
reason: new Variant('s', 'Stretchly needs to run in the background to remind you to take breaks'),
62+
autostart: new Variant('b', enabled),
63+
'dbus-activatable': new Variant('b', false)
64+
}
65+
66+
// When disabling autostart, the portal doesn't create a persistent request object.
67+
// No Response signal is emitted, so we can return immediately.
68+
if (!enabled) {
69+
try {
70+
await background.RequestBackground('', options)
71+
log.info('Stretchly: Autostart disabled via XDG Portal')
72+
return true
73+
} catch (error) {
74+
log.error('Stretchly: Failed to disable autostart via XDG Portal:', error)
75+
return false
76+
}
77+
}
78+
79+
// When enabling autostart, we must wait for the Response signal.
80+
// We start listening BEFORE calling the method to avoid race conditions.
81+
const responsePromise = this._waitForBusResponse(handleToken, enabled)
82+
83+
const requestPath = await background.RequestBackground('', options)
84+
log.info(`Stretchly: RequestBackground called, request path: ${requestPath}`)
85+
86+
return await responsePromise
87+
} catch (error) {
88+
log.error(`Stretchly: Failed to set autostart=${enabled} via XDG Portal:`, error)
89+
return false
90+
}
91+
}
92+
93+
/**
94+
* Waits for the Portal Request Response signal.
95+
* @private
96+
*/
97+
_waitForBusResponse (handleToken, expectingEnabled) {
98+
return new Promise((resolve) => {
99+
let timeoutId = null
100+
let messageListener = null
101+
102+
const cleanup = () => {
103+
if (timeoutId) {
104+
clearTimeout(timeoutId)
105+
timeoutId = null
106+
}
107+
if (messageListener && this.bus) {
108+
this.bus.off('message', messageListener)
109+
messageListener = null
110+
}
111+
}
112+
113+
messageListener = (msg) => {
114+
if (msg.interface === 'org.freedesktop.portal.Request' &&
115+
msg.member === 'Response' &&
116+
msg.path.endsWith(handleToken)) {
117+
const [response, results] = msg.body
118+
119+
cleanup()
120+
121+
log.info(`Stretchly: Portal Response signal received - response: ${response}, results:`, results)
122+
123+
// Response codes: 0 = success, 1 = user cancelled, 2 = other error
124+
if (response === 0) {
125+
const autostartGranted = results && results.autostart && results.autostart.value === expectingEnabled
126+
if (autostartGranted) {
127+
log.info('Stretchly: Autostart enabled via XDG Portal')
128+
} else {
129+
log.warn('Stretchly: Autostart status did not match request', results)
130+
}
131+
resolve(autostartGranted)
132+
} else if (response === 1) {
133+
log.warn('Stretchly: User cancelled the portal request')
134+
resolve(false)
135+
} else {
136+
log.error(`Stretchly: Portal request failed with response code: ${response}`)
137+
resolve(false)
138+
}
139+
}
140+
}
141+
142+
timeoutId = setTimeout(() => {
143+
cleanup()
144+
log.error(`Stretchly: Portal request timeout after ${this.portalRequestTimeoutMs / 1000} seconds`)
145+
resolve(false)
146+
}, this.portalRequestTimeoutMs)
147+
148+
this.bus.on('message', messageListener)
149+
})
150+
}
151+
152+
async enableAutostart () {
153+
try {
154+
const success = await this.setAutostart(true)
155+
if (success) {
156+
this.settings.set('flatpakAutostart', true)
157+
}
158+
return success
159+
} catch (error) {
160+
log.error('Stretchly: Failed to set autostart (enable) via XDG Portal', error)
161+
return false
162+
}
163+
}
164+
165+
async disableAutostart () {
166+
try {
167+
const success = await this.setAutostart(false)
168+
if (success) {
169+
this.settings.set('flatpakAutostart', false)
170+
}
171+
return success
172+
} catch (error) {
173+
log.error('Stretchly: Failed to set autostart (disable) via XDG Portal', error)
174+
return false
175+
}
176+
}
177+
178+
async isAutostartEnabled () {
179+
// XDG portals don't provide a reliable query method, so we read from our cache
180+
return this.settings.get('flatpakAutostart')
181+
}
182+
183+
disconnect () {
184+
if (this.bus) {
185+
this.bus.disconnect()
186+
this.bus = null
187+
this.portal = null
188+
this.initialized = false
189+
log.info('Stretchly: XDG Background Portal disconnected')
190+
}
191+
}
192+
}
193+
194+
export default FlatpakPortalManager

app/utils/utils.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,15 @@ function shouldShowNotificationTitle (platform, systemVersion, semver) {
7676
}
7777

7878
function insideFlatpak () {
79-
const flatpakInfoPath = '/.flatpak-info'
80-
return fs.existsSync(flatpakInfoPath)
79+
return process.platform === 'linux' && fs.existsSync('/.flatpak-info')
8180
}
8281

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

8786
function insideSnap () {
88-
return !!process.env.SNAP
87+
return process.platform === 'linux' && !!process.env.SNAP
8988
}
9089

9190
export {

0 commit comments

Comments
 (0)