mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 19:50:30 +00:00
desktop: deep links on macOS, Windows and Linux (#7605)
* tests: close the walk stream in withTempDir * desktop: receive app links from args and macOS * desktop: forward an app link to the running app * desktop: initialize the desktop platform lazily * tests: share the temp directory helper * desktop: register simplexchat scheme in packages * desktop: register simplexchat scheme at runtime * ui: end the badge page with a link on desktop * desktop: raise the window over other apps * docs: describe desktop deep links in the spec * docs: plan desktop deep links * docs: report on desktop deep links * desktop: open simplex connection links * docs: describe simplex links on desktop * desktop: keep a link from opening twice * desktop: retry taking a signal held open * desktop: keep the user's AppImage link handler * desktop: register Windows links through Capabilities * desktop: add %u to the deb entry only once * docs: describe the review fixes * ui: open the badge link screen on the left * ui: import moko accessors in the badge screens --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
This commit is contained in:
@@ -285,9 +285,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file)
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md |
|
||||
| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
|
||||
| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
|
||||
| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
|
||||
| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
|
||||
| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
|
||||
| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
|
||||
| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
|
||||
| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
|
||||
| common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md |
|
||||
|
||||
+3
@@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground
|
||||
|
||||
// the manifest declares the scheme
|
||||
actual fun appLinkSchemeRegistered(): Boolean = true
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var androidAppContext: Context
|
||||
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ expect val deviceName: String
|
||||
|
||||
expect fun isAppVisibleAndFocused(): Boolean
|
||||
|
||||
expect fun appLinkSchemeRegistered(): Boolean
|
||||
|
||||
val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
|
||||
BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE
|
||||
else
|
||||
|
||||
+3
-3
@@ -52,10 +52,10 @@ fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
|
||||
|
||||
// the page's app flag rides in the fragment, which never reaches the service
|
||||
// TEST ONLY: pointed at the dev deployment; restore the two lines below before merging
|
||||
val badgePageUrl: String =
|
||||
if (appPlatform.isAndroid) "https://smp7.simplex.im/#/tier?app=true"
|
||||
fun badgePageUrl(linkReturns: Boolean): String =
|
||||
if (linkReturns) "https://smp7.simplex.im/#/tier?app=true"
|
||||
else "https://smp7.simplex.im/#/tier?app=desktop"
|
||||
// if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true"
|
||||
// if (linkReturns) "https://badges.simplex.chat/#/tier?app=true"
|
||||
// else "https://badges.simplex.chat/#/tier?app=desktop"
|
||||
|
||||
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
|
||||
|
||||
+4
-4
@@ -262,7 +262,7 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
private var badgeLinkStep: MutableState<BadgeLinkStep>? = null
|
||||
|
||||
private fun isBadgeLinkOpen(): Boolean =
|
||||
ModalManager.end.hasModalOpen(ModalViewId.BADGE_LINK)
|
||||
ModalManager.start.hasModalOpen(ModalViewId.BADGE_LINK)
|
||||
|
||||
fun isBadgeLinkIssuing(): Boolean =
|
||||
isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing
|
||||
@@ -275,7 +275,7 @@ fun openBadgeLink(rhId: Long?, codeText: String) {
|
||||
// held by the modal, not remembered: a modal is composed only while on top, and rotation recreates the activity,
|
||||
// either of which would reset remembered state to Confirming with a request in flight
|
||||
val step = mutableStateOf(BadgeLinkStep.Confirming)
|
||||
ModalManager.end.showCustomModal(id = ModalViewId.BADGE_LINK) { close ->
|
||||
ModalManager.start.showCustomModal(id = ModalViewId.BADGE_LINK) { close ->
|
||||
BadgesRedeemLinkView(rhId, code, step, close)
|
||||
}
|
||||
badgeLinkStep = step
|
||||
@@ -298,7 +298,7 @@ fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState<BadgeLink
|
||||
badgeLinkStep === step && isBadgeLinkOpen()
|
||||
|
||||
fun isShowing(): Boolean =
|
||||
isOpen() && ModalManager.end.isLastModalOpen(ModalViewId.BADGE_LINK)
|
||||
isOpen() && ModalManager.start.isLastModalOpen(ModalViewId.BADGE_LINK)
|
||||
|
||||
// An outcome can arrive after this screen was closed or covered: it must not close another screen,
|
||||
// and a covered one goes back to asking, so it is never left locked on the spinner.
|
||||
@@ -352,7 +352,7 @@ fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState<BadgeLink
|
||||
}
|
||||
}
|
||||
BadgeLinkStep.Issuing -> ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) }
|
||||
BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.end, close)
|
||||
BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.start, close)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -130,7 +130,7 @@ private fun BuyInBrowserTextButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
TextButtonBelowOnboardingButton(
|
||||
text = stringResource(MR.strings.badges_buy_in_browser),
|
||||
onClick = { uriHandler.openUriCatching(badgePageUrl) }
|
||||
onClick = { uriHandler.openUriCatching(badgePageUrl(appLinkSchemeRegistered())) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,9 +142,10 @@ private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) {
|
||||
labelId = MR.strings.badges_buy_in_browser,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
uriHandler.openUriCatching(badgePageUrl)
|
||||
// desktop has no scheme to bring the code back, so the code is pasted into this screen, opened beside the browser
|
||||
if (appPlatform.isDesktop && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) {
|
||||
val linkReturns = appLinkSchemeRegistered()
|
||||
uriHandler.openUriCatching(badgePageUrl(linkReturns))
|
||||
// with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser
|
||||
if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) {
|
||||
modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) }
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -789,13 +789,17 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
// the app's own scheme, for every link that is not a connection link, which stays on simplex:
|
||||
private const val appLinkScheme = "simplexchat"
|
||||
internal const val appLinkScheme = "simplexchat"
|
||||
internal const val connectionLinkScheme = "simplex"
|
||||
private const val badgeLinkPath = "/badge/code/"
|
||||
|
||||
// the prefix of the raw text, not a parsed URI: a link that does not parse is still an app link
|
||||
fun isAppLink(uri: String): Boolean =
|
||||
uri.startsWith("$appLinkScheme:", ignoreCase = true)
|
||||
|
||||
fun isConnectionLink(uri: String): Boolean =
|
||||
uri.startsWith("$connectionLinkScheme:", ignoreCase = true)
|
||||
|
||||
// a link type added in a later version reaches this build too, so an unknown path asks for an update
|
||||
private fun openAppLink(rhId: Long?, uri: String) {
|
||||
// a link that does not parse is dropped, as iOS never receives one; one that parses without
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.views.badges.badgePageUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BadgePageUrlTest {
|
||||
@Test
|
||||
fun pageEndsWithALinkBackWhenTheLinkReturnsToTheApp() {
|
||||
val url = badgePageUrl(linkReturns = true)
|
||||
assertTrue(url.endsWith("#/tier?app=true"), "url: $url")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pageEndsWithACodeToPasteWhenNoLinkReturns() {
|
||||
val url = badgePageUrl(linkReturns = false)
|
||||
assertTrue(url.endsWith("#/tier?app=desktop"), "url: $url")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.chatlist.isAppLink
|
||||
import chat.simplex.common.views.chatlist.isConnectionLink
|
||||
import java.awt.Desktop
|
||||
|
||||
// A one-time link with its post-quantum key is about 2 KB, near 4 KB with a second such key;
|
||||
// the bound caps what the app accepts from outside.
|
||||
internal const val MAX_LINK_BYTES = 8192
|
||||
|
||||
internal fun isAcceptedLink(uri: String): Boolean =
|
||||
uri.toByteArray(Charsets.UTF_8).size <= MAX_LINK_BYTES && (isAppLink(uri) || isConnectionLink(uri))
|
||||
|
||||
fun linkFromArgs(args: Array<String>): String? =
|
||||
args.singleOrNull()?.takeIf(::isAcceptedLink)
|
||||
|
||||
// The link is stored before showWindow, so a failure to show the window cannot lose it.
|
||||
internal fun openDesktopLink(uri: String) {
|
||||
chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri
|
||||
showWindow()
|
||||
}
|
||||
|
||||
// macOS delivers a link as an Apple Event, never in args; the JDK queues it until a handler is set
|
||||
fun installOpenUriHandler() {
|
||||
if (!Desktop.isDesktopSupported()) return
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return
|
||||
desktop.setOpenURIHandler { event ->
|
||||
val uri = event.uri.toString()
|
||||
if (isAcceptedLink(uri)) openDesktopLink(uri)
|
||||
}
|
||||
}
|
||||
@@ -259,11 +259,16 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Bool
|
||||
fun showWindow() {
|
||||
simplexWindowState.windowVisible.value = true
|
||||
simplexWindowState.window?.apply {
|
||||
// During a crash restart this is the disposed window, and showing it would bring it back.
|
||||
if (!isDisplayable) return
|
||||
// Clear ICONIFIED so a minimized window un-minimizes; preserves MAXIMIZED_BOTH
|
||||
// when set. toFront() alone does not un-minimize on any AWT platform.
|
||||
extendedState = extendedState and Frame.ICONIFIED.inv()
|
||||
// The window is made visible before the activation below; the next recomposition would be too late.
|
||||
isVisible = true
|
||||
toFront()
|
||||
requestFocus()
|
||||
if (desktopPlatform.isLinux()) requestX11Activation(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+158
-35
@@ -3,59 +3,65 @@ package chat.simplex.common
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.TAG
|
||||
import chat.simplex.common.platform.dataDir
|
||||
import chat.simplex.common.platform.desktopPlatform
|
||||
import chat.simplex.res.*
|
||||
import com.sun.jna.NativeLibrary
|
||||
import java.io.IOException
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
import java.nio.file.*
|
||||
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
|
||||
import java.nio.file.StandardOpenOption.CREATE
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
import java.nio.file.StandardOpenOption.WRITE
|
||||
import java.nio.file.attribute.FileTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private var lockHandle: FileLock? = null
|
||||
private var watcher: WatchService? = null
|
||||
|
||||
internal const val SHOW_FILE = "simplex.show"
|
||||
internal const val SHOW_TMP_SUFFIX = ".tmp"
|
||||
internal const val TAKEN_SHOW_FILE = "$SHOW_FILE.taken$SHOW_TMP_SUFFIX"
|
||||
// File times can lag the system clock by a clock tick, and by 2 s on FAT.
|
||||
internal val FILE_TIME_TOLERANCE: Duration = Duration.ofSeconds(2)
|
||||
private const val SIGNAL_WAIT_MILLIS = 1000L
|
||||
private const val SIGNAL_POLL_MILLIS = 50L
|
||||
private const val SIGNAL_RETRY_MILLIS = 100L
|
||||
|
||||
private val lockPath get() = dataDir.resolve("simplex.started").toPath()
|
||||
private val showPath get() = dataDir.resolve("simplex.show").toPath()
|
||||
|
||||
var singleInstanceLock = false
|
||||
private set
|
||||
|
||||
internal class ShowSignal(val link: String?)
|
||||
|
||||
private sealed interface LockResult {
|
||||
class Acquired(val lock: FileLock) : LockResult
|
||||
object Taken : LockResult
|
||||
object Failed : LockResult
|
||||
}
|
||||
|
||||
fun acquireSingleInstance(): Boolean {
|
||||
fun acquireSingleInstance(link: String?): Boolean {
|
||||
dataDir.mkdirs()
|
||||
val lockAttemptTime = FileTime.from(Instant.now())
|
||||
when (val result = tryAcquireLock()) {
|
||||
is LockResult.Acquired -> {
|
||||
lockHandle = result.lock
|
||||
singleInstanceLock = true
|
||||
deleteShowFile()
|
||||
startShowFileWatcher()
|
||||
deleteStaleSignalFiles(dataDir.toPath(), lockAttemptTime)
|
||||
return true
|
||||
}
|
||||
LockResult.Failed -> {
|
||||
return true
|
||||
}
|
||||
LockResult.Taken -> {
|
||||
// Ensure the signal file exists (createShowFile is a no-op if it does)
|
||||
// and wait up to 1s for the primary's watcher to consume it. If still
|
||||
// there after the wait, the primary is hung — let the user decide.
|
||||
createShowFile()
|
||||
val deadline = System.currentTimeMillis() + 1000
|
||||
while (Files.exists(showPath) && System.currentTimeMillis() < deadline) {
|
||||
try { Thread.sleep(50) } catch (_: InterruptedException) { break }
|
||||
}
|
||||
if (!Files.exists(showPath)) return false
|
||||
val start = showSingleInstanceAlert()
|
||||
if (start) deleteShowFile()
|
||||
return start
|
||||
if (desktopPlatform.isWindows()) allowPrimaryForeground()
|
||||
return startDespiteRunningInstance(dataDir.toPath(), link, SIGNAL_WAIT_MILLIS, ::showSingleInstanceAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,20 +90,129 @@ private fun tryAcquireLock(): LockResult {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteShowFile() {
|
||||
try { Files.deleteIfExists(showPath) } catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot delete show file: ${e.message}")
|
||||
// A signal still present after the wait means the running instance is hung, so the user decides.
|
||||
internal fun startDespiteRunningInstance(dir: Path, link: String?, waitMillis: Long, askToStart: () -> Boolean): Boolean {
|
||||
signalRunningInstance(dir, link)
|
||||
val show = dir.resolve(SHOW_FILE)
|
||||
val deadline = System.currentTimeMillis() + waitMillis
|
||||
while (Files.exists(show) && System.currentTimeMillis() < deadline) {
|
||||
try { Thread.sleep(SIGNAL_POLL_MILLIS) } catch (_: InterruptedException) { break }
|
||||
}
|
||||
if (!Files.exists(show) || !askToStart()) return false
|
||||
// The running instance can recover while the alert is up; once it has taken the signal, it opens the link itself.
|
||||
return try {
|
||||
Files.deleteIfExists(show)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot withdraw signal file: ${e.message}")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createShowFile() {
|
||||
try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) {
|
||||
// Another duplicate already signalled; primary will pick it up.
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot create show file: ${e.message}")
|
||||
private fun deleteSignalFile(path: Path) {
|
||||
try { Files.deleteIfExists(path) } catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot delete signal file: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun deleteStaleSignalFiles(dir: Path, lockAttemptTime: FileTime) {
|
||||
val staleBefore = FileTime.from(lockAttemptTime.toInstant().minus(FILE_TIME_TOLERANCE))
|
||||
try {
|
||||
// only a running instance takes a signal, so a taken file at startup was left by a crash
|
||||
Files.deleteIfExists(dir.resolve(TAKEN_SHOW_FILE))
|
||||
Files.newDirectoryStream(dir, "$SHOW_FILE*").use { files ->
|
||||
files.forEach { file ->
|
||||
try {
|
||||
if (Files.getLastModifiedTime(file) < staleBefore) Files.deleteIfExists(file)
|
||||
} catch (_: NoSuchFileException) {
|
||||
// a second process renamed its temp file meanwhile
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.message}")
|
||||
} catch (e: DirectoryIteratorException) {
|
||||
Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.cause?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// The temp file is owner-only on POSIX, as the link is a bearer secret, and the rename makes it appear whole.
|
||||
internal fun signalRunningInstance(dir: Path, link: String?) {
|
||||
var tmp: Path? = null
|
||||
try {
|
||||
tmp = Files.createTempFile(dir, SHOW_FILE, SHOW_TMP_SUFFIX)
|
||||
Files.write(tmp, (link ?: "").toByteArray(Charsets.UTF_8))
|
||||
Files.move(tmp, dir.resolve(SHOW_FILE), ATOMIC_MOVE)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot signal running instance: ${e.message}")
|
||||
tmp?.let(::deleteSignalFile)
|
||||
// an empty signal needs no free space and still brings the running instance forward
|
||||
try {
|
||||
Files.createFile(dir.resolve(SHOW_FILE))
|
||||
} catch (_: FileAlreadyExistsException) {
|
||||
// another process has already signalled
|
||||
} catch (createError: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot create signal file: ${createError.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The signal is renamed before it is read, so a newer signal renamed over it meanwhile is not deleted unread.
|
||||
internal fun takeSignal(dir: Path, move: (Path, Path) -> Unit = ::moveAtomically): ShowSignal? {
|
||||
val taken = dir.resolve(TAKEN_SHOW_FILE)
|
||||
try {
|
||||
moveWithRetry(dir.resolve(SHOW_FILE), taken, move)
|
||||
} catch (_: NoSuchFileException) {
|
||||
return null
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot take signal file: ${e.message}")
|
||||
return ShowSignal(null)
|
||||
}
|
||||
val bytes = try {
|
||||
Files.newInputStream(taken).use { it.readNBytes(MAX_LINK_BYTES + 1) }
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot read signal file: ${e.message}")
|
||||
null
|
||||
} finally {
|
||||
deleteSignalFile(taken)
|
||||
}
|
||||
return ShowSignal(bytes?.toString(Charsets.UTF_8)?.takeIf(::isAcceptedLink))
|
||||
}
|
||||
|
||||
private fun moveAtomically(from: Path, to: Path) {
|
||||
Files.move(from, to, ATOMIC_MOVE)
|
||||
}
|
||||
|
||||
// Another program, such as an antivirus scanner on Windows, can hold the file open for a moment.
|
||||
private fun moveWithRetry(from: Path, to: Path, move: (Path, Path) -> Unit) {
|
||||
try {
|
||||
move(from, to)
|
||||
} catch (e: NoSuchFileException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
try {
|
||||
Thread.sleep(SIGNAL_RETRY_MILLIS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw e
|
||||
}
|
||||
move(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// Win32 ASFW_ANY lets every process take the foreground
|
||||
private const val ASFW_ANY = -1
|
||||
|
||||
// Windows lets only the foreground process raise a window; the launching browser passed that right to this process.
|
||||
private fun allowPrimaryForeground() {
|
||||
val allowed = try {
|
||||
NativeLibrary.getInstance("user32").getFunction("AllowSetForegroundWindow").invokeInt(arrayOf<Any>(ASFW_ANY)) != 0
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, "single-instance: AllowSetForegroundWindow unavailable: ${e.message}")
|
||||
return
|
||||
}
|
||||
if (!allowed) Log.w(TAG, "single-instance: AllowSetForegroundWindow refused")
|
||||
}
|
||||
|
||||
private fun showSingleInstanceAlert(): Boolean {
|
||||
val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title)
|
||||
val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding)
|
||||
@@ -109,26 +224,34 @@ private fun showSingleInstanceAlert(): Boolean {
|
||||
return result == javax.swing.JOptionPane.YES_OPTION
|
||||
}
|
||||
|
||||
private fun startShowFileWatcher() {
|
||||
fun startShowFileWatcher() {
|
||||
if (watcher != null) return
|
||||
val dir = dataDir.toPath()
|
||||
val ws = try {
|
||||
dataDir.toPath().fileSystem.newWatchService()
|
||||
dir.fileSystem.newWatchService()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: WatchService failed: ${e.message}")
|
||||
return
|
||||
}
|
||||
dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE)
|
||||
dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE)
|
||||
watcher = ws
|
||||
thread(name = "simplex-single-instance", isDaemon = true) {
|
||||
while (true) {
|
||||
val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread }
|
||||
for (event in key.pollEvents()) {
|
||||
if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") {
|
||||
deleteShowFile()
|
||||
SwingUtilities.invokeLater { showWindow() }
|
||||
}
|
||||
watchShowSignals(ws, dir) { signal ->
|
||||
SwingUtilities.invokeLater {
|
||||
if (signal.link != null) openDesktopLink(signal.link) else showWindow()
|
||||
}
|
||||
if (!key.reset()) return@thread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dir must already be registered with ws; a signal written before that raised no event, so it is taken first.
|
||||
internal fun watchShowSignals(ws: WatchService, dir: Path, onSignal: (ShowSignal) -> Unit) {
|
||||
takeSignal(dir)?.let(onSignal)
|
||||
while (true) {
|
||||
val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return } catch (_: InterruptedException) { return }
|
||||
for (event in key.pollEvents()) {
|
||||
if ((event.context() as? Path)?.fileName?.toString() == SHOW_FILE) takeSignal(dir)?.let(onSignal)
|
||||
}
|
||||
if (!key.reset()) return
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.TAG
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.NativeLong
|
||||
import com.sun.jna.platform.unix.X11
|
||||
import java.awt.Toolkit
|
||||
import java.awt.Window
|
||||
|
||||
// EWMH source indication of a pager, which window managers exempt from focus stealing prevention
|
||||
private const val NET_ACTIVE_WINDOW_SOURCE_PAGER = 2L
|
||||
private const val CLIENT_MESSAGE_FORMAT_BITS = 32
|
||||
|
||||
// AWT's toFront() sends only XRaiseWindow, which wlroots compositors (Sway) drop and focus stealing
|
||||
// prevention refuses; _NET_ACTIVE_WINDOW is the request X11 and XWayland window managers act on.
|
||||
internal fun requestX11Activation(window: Window) {
|
||||
val x11: X11
|
||||
val windowId: Long
|
||||
try {
|
||||
x11 = X11.INSTANCE
|
||||
windowId = Native.getWindowID(window)
|
||||
} catch (e: LinkageError) {
|
||||
Log.w(TAG, "window activation: native library unavailable: ${e.message}")
|
||||
return
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "window activation: no native window: ${e.message}")
|
||||
return
|
||||
}
|
||||
// The map request goes out on AWT's connection; flushing it first lets the window manager map the window before activating it.
|
||||
Toolkit.getDefaultToolkit().sync()
|
||||
sendActivation(x11, windowId)
|
||||
}
|
||||
|
||||
internal fun sendActivation(x11: X11, windowId: Long) {
|
||||
val display = x11.XOpenDisplay(null)
|
||||
if (display == null) {
|
||||
Log.w(TAG, "window activation: cannot open the X display")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val event = activationEvent(X11.Window(windowId), x11.XInternAtom(display, "_NET_ACTIVE_WINDOW", false))
|
||||
val mask = NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong())
|
||||
x11.XSendEvent(display, x11.XDefaultRootWindow(display), 0, mask, event)
|
||||
x11.XFlush(display)
|
||||
} finally {
|
||||
x11.XCloseDisplay(display)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun activationEvent(window: X11.Window, activeWindowAtom: X11.Atom): X11.XEvent {
|
||||
val event = X11.XEvent()
|
||||
event.setType(X11.XClientMessageEvent::class.java)
|
||||
event.xclient.type = X11.ClientMessage
|
||||
event.xclient.window = window
|
||||
event.xclient.message_type = activeWindowAtom
|
||||
event.xclient.format = CLIENT_MESSAGE_FORMAT_BITS
|
||||
event.xclient.data.setType(Array<NativeLong>::class.java)
|
||||
event.xclient.data.l[0] = NativeLong(NET_ACTIVE_WINDOW_SOURCE_PAGER)
|
||||
// the request carries no user interaction timestamp
|
||||
event.xclient.data.l[1] = NativeLong(X11.CurrentTime.toLong())
|
||||
return event
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import chat.simplex.common.views.chatlist.appLinkScheme
|
||||
import chat.simplex.common.views.chatlist.connectionLinkScheme
|
||||
import com.sun.jna.NativeLibrary
|
||||
import com.sun.jna.WString
|
||||
import com.sun.jna.platform.win32.Advapi32Util
|
||||
import com.sun.jna.platform.win32.Win32Exception
|
||||
import com.sun.jna.platform.win32.WinReg.HKEY
|
||||
import com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER
|
||||
import com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFileAttributes
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
internal const val APP_LINK_MIME_TYPE = "x-scheme-handler/$appLinkScheme"
|
||||
internal const val CONNECTION_LINK_MIME_TYPE = "x-scheme-handler/$connectionLinkScheme"
|
||||
private const val WINDOWS_CLASSES_KEY = "Software\\Classes"
|
||||
private const val WINDOWS_PROG_ID_PREFIX = "SimpleX."
|
||||
private const val WINDOWS_CAPABILITIES_KEY = "Software\\chat.simplex.app\\Capabilities"
|
||||
private const val WINDOWS_REGISTERED_APPLICATIONS_KEY = "Software\\RegisteredApplications"
|
||||
private const val APP_DESCRIPTION = "Private and secure open-source messenger - no user IDs (not even random numbers)"
|
||||
private const val WINDOWS_FIRST_ICON_INDEX = 0
|
||||
// values from shlwapi.h and shlobj_core.h
|
||||
private const val ASSOCF_IS_PROTOCOL = 0x1000
|
||||
private const val ASSOCSTR_EXECUTABLE = 2
|
||||
private const val S_OK = 0
|
||||
private const val S_FALSE = 1
|
||||
private const val SHCNE_ASSOCCHANGED = 0x08000000
|
||||
private const val SHCNF_IDLIST = 0
|
||||
// the Win32 name of a key's default value
|
||||
private const val REGISTRY_DEFAULT_VALUE = ""
|
||||
internal const val APPIMAGE_ENTRY_NAME = "chat.simplex.app-links.desktop"
|
||||
private const val APPLICATIONS_DIR = "applications"
|
||||
// the XDG Base Directory default when XDG_DATA_DIRS is unset
|
||||
private const val DEFAULT_XDG_DATA_DIRS = "/usr/local/share:/usr/share"
|
||||
private const val APP_DISPLAY_NAME = "SimpleX Chat"
|
||||
private const val XDG_MIME = "xdg-mime"
|
||||
// jpackage names the deb's desktop entry <package>-<app>.desktop
|
||||
private const val DEB_ENTRY_NAME = "simplex-simplex.desktop"
|
||||
private const val PROCESS_TIMEOUT_SECONDS = 5L
|
||||
private const val ROOT_USER = "root"
|
||||
// the characters the desktop entry spec requires an Exec argument to quote
|
||||
private const val EXEC_RESERVED_CHARACTERS = " \t\n\"'\\><~|&;$*?#()`"
|
||||
|
||||
internal sealed interface DesktopInstallation {
|
||||
data object MacBundle : DesktopInstallation
|
||||
data class WindowsExe(val path: String) : DesktopInstallation
|
||||
data object Flatpak : DesktopInstallation
|
||||
data class AppImage(val path: String) : DesktopInstallation
|
||||
data object LinuxPackage : DesktopInstallation
|
||||
data object Unpackaged : DesktopInstallation
|
||||
}
|
||||
|
||||
// Only simplexchat: decides how the badge page ends, so simplex: is registered without being checked here.
|
||||
@Volatile
|
||||
private var registered = false
|
||||
|
||||
actual fun appLinkSchemeRegistered(): Boolean = registered
|
||||
|
||||
fun registerLinkSchemes() {
|
||||
thread(name = "simplex-link-schemes", isDaemon = true) {
|
||||
registered = registerForThisInstallation()
|
||||
}
|
||||
}
|
||||
|
||||
// appPath is jpackage.app-path, set by jpackage's launcher, so only a packaged app has one.
|
||||
internal fun desktopInstallation(platform: DesktopPlatform, appPath: String?, env: Map<String, String>): DesktopInstallation {
|
||||
val appImage = env["APPIMAGE"]
|
||||
val appDir = env["APPDIR"]
|
||||
return when {
|
||||
appPath == null -> DesktopInstallation.Unpackaged
|
||||
platform.isMac() -> DesktopInstallation.MacBundle
|
||||
platform.isWindows() -> DesktopInstallation.WindowsExe(appPath)
|
||||
env["container"] == "flatpak" -> DesktopInstallation.Flatpak
|
||||
// The AppImage runtime exports APPIMAGE to every child process, so it describes this app only
|
||||
// when the launcher runs from the AppImage's own mount.
|
||||
appImage != null && appDir != null && Path.of(appPath).startsWith(realPathOrSelf(appDir)) -> DesktopInstallation.AppImage(appImage)
|
||||
else -> DesktopInstallation.LinuxPackage
|
||||
}
|
||||
}
|
||||
|
||||
// jpackage.app-path is resolved through /proc/self/exe, while APPDIR keeps any symlink in TMPDIR.
|
||||
private fun realPathOrSelf(path: String): Path =
|
||||
try {
|
||||
Path.of(path).toRealPath()
|
||||
} catch (_: IOException) {
|
||||
Path.of(path)
|
||||
}
|
||||
|
||||
private fun registerForThisInstallation(): Boolean =
|
||||
when (val installation = desktopInstallation(desktopPlatform, System.getProperty("jpackage.app-path"), System.getenv())) {
|
||||
// The bundle's Info.plist and the Flatpak's exported desktop entry register the schemes.
|
||||
DesktopInstallation.MacBundle, DesktopInstallation.Flatpak -> true
|
||||
is DesktopInstallation.WindowsExe -> registerWindowsSchemes(installation.path, SystemWindowsShell)
|
||||
is DesktopInstallation.AppImage ->
|
||||
registerAppImageScheme(installation.path, File(unixDataHome, APPLICATIONS_DIR), systemApplicationsDirs(), ::runProcess)
|
||||
DesktopInstallation.LinuxPackage -> linuxPackageRegistered(::runProcess)
|
||||
DesktopInstallation.Unpackaged -> false
|
||||
}
|
||||
|
||||
internal fun windowsOpenCommand(appPath: String): String = "\"$appPath\" \"%1\""
|
||||
|
||||
internal fun windowsCommandProgram(command: String): String =
|
||||
if (command.startsWith("\"")) command.substring(1).substringBefore('"') else command.substringBefore(' ')
|
||||
|
||||
internal data class RegistryValue(val key: String, val name: String, val value: String)
|
||||
|
||||
private fun windowsSchemeKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\$scheme"
|
||||
|
||||
private fun windowsCommandKey(scheme: String): String = "${windowsSchemeKey(scheme)}\\shell\\open\\command"
|
||||
|
||||
private fun windowsProgId(scheme: String): String = "$WINDOWS_PROG_ID_PREFIX$scheme"
|
||||
|
||||
private fun windowsProgIdKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\${windowsProgId(scheme)}"
|
||||
|
||||
private fun windowsProgIdCommandKey(scheme: String): String = "${windowsProgIdKey(scheme)}\\shell\\open\\command"
|
||||
|
||||
private fun windowsIcon(appPath: String): String = "\"$appPath\",$WINDOWS_FIRST_ICON_INDEX"
|
||||
|
||||
// The scheme key declares the URL scheme, but its command opens links only while no app registered through
|
||||
// Capabilities claims the scheme: Windows settings offer only the ProgIds that Capabilities name.
|
||||
internal fun windowsSchemeValues(scheme: String, appPath: String): List<RegistryValue> {
|
||||
val schemeKey = windowsSchemeKey(scheme)
|
||||
return listOf(
|
||||
RegistryValue(key = schemeKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"),
|
||||
// without this value Windows does not treat the key as a URL scheme
|
||||
RegistryValue(key = schemeKey, name = "URL Protocol", value = ""),
|
||||
RegistryValue(key = "$schemeKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)),
|
||||
RegistryValue(key = windowsCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)),
|
||||
RegistryValue(key = "$WINDOWS_CAPABILITIES_KEY\\URLAssociations", name = scheme, value = windowsProgId(scheme)),
|
||||
)
|
||||
}
|
||||
|
||||
// The ProgId is what links resolve to once the user picks SimpleX in settings, so it is repaired even while
|
||||
// another program holds the scheme key, but another installed copy's ProgId is kept like any other handler.
|
||||
internal fun windowsProgIdValues(scheme: String, appPath: String): List<RegistryValue> {
|
||||
val progIdKey = windowsProgIdKey(scheme)
|
||||
return listOf(
|
||||
RegistryValue(key = progIdKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"),
|
||||
RegistryValue(key = "$progIdKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)),
|
||||
RegistryValue(key = windowsProgIdCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)),
|
||||
)
|
||||
}
|
||||
|
||||
// The application is listed last, so Windows never lists it before its capabilities are complete.
|
||||
internal val windowsApplicationValues: List<RegistryValue> = listOf(
|
||||
RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationName", value = APP_DISPLAY_NAME),
|
||||
RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationDescription", value = APP_DESCRIPTION),
|
||||
RegistryValue(key = WINDOWS_REGISTERED_APPLICATIONS_KEY, name = APP_DISPLAY_NAME, value = WINDOWS_CAPABILITIES_KEY),
|
||||
)
|
||||
|
||||
internal interface WindowsShell {
|
||||
fun readUser(key: String, name: String): String?
|
||||
fun readMachine(key: String, name: String): String?
|
||||
fun writeUser(value: RegistryValue)
|
||||
// the program the shell starts for a link, after any choice made in Windows settings
|
||||
fun linkHandler(scheme: String): String?
|
||||
fun notifyAssociationsChanged()
|
||||
}
|
||||
|
||||
internal fun registerWindowsSchemes(appPath: String, shell: WindowsShell): Boolean {
|
||||
var changed = false
|
||||
val tracked = object : WindowsShell by shell {
|
||||
override fun writeUser(value: RegistryValue) {
|
||||
shell.writeUser(value)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
val associated = listOf(appLinkScheme, connectionLinkScheme).filter { registerWindowsScheme(it, appPath, tracked) }
|
||||
if (associated.isNotEmpty()) writeMissing(windowsApplicationValues, tracked, "the application")
|
||||
// The notification also clears Explorer's icon cache, so it is sent only after a change.
|
||||
if (changed) shell.notifyAssociationsChanged()
|
||||
return shell.linkHandler(appLinkScheme)?.let { isThisProgram(it, appPath) } == true
|
||||
}
|
||||
|
||||
private fun registerWindowsScheme(scheme: String, appPath: String, shell: WindowsShell): Boolean =
|
||||
!heldByAnotherProgram(windowsProgIdCommandKey(scheme), appPath, shell) &&
|
||||
writeMissing(windowsProgIdValues(scheme, appPath), shell, "the $scheme: ProgId") &&
|
||||
!heldByAnotherProgram(windowsCommandKey(scheme), appPath, shell) &&
|
||||
writeMissing(windowsSchemeValues(scheme, appPath), shell, "the $scheme: scheme")
|
||||
|
||||
private fun isThisProgram(program: String, appPath: String): Boolean =
|
||||
File(program).absolutePath.equals(File(appPath).absolutePath, ignoreCase = true)
|
||||
|
||||
// A per-user key hides the machine-wide one, so the machine-wide command counts only without it.
|
||||
private fun heldByAnotherProgram(commandKey: String, appPath: String, shell: WindowsShell): Boolean =
|
||||
try {
|
||||
val current = shell.readUser(commandKey, REGISTRY_DEFAULT_VALUE) ?: shell.readMachine(commandKey, REGISTRY_DEFAULT_VALUE)
|
||||
val program = current?.let(::windowsCommandProgram)
|
||||
program != null && !isThisProgram(program, appPath) && File(program).isFile
|
||||
} catch (e: Win32Exception) {
|
||||
Log.w(TAG, "link scheme: cannot read the handler under $commandKey: ${e.message}")
|
||||
true
|
||||
}
|
||||
|
||||
private fun writeMissing(values: List<RegistryValue>, shell: WindowsShell, what: String): Boolean {
|
||||
fun stored() = values.all { shell.readUser(it.key, it.name) == it.value }
|
||||
return try {
|
||||
if (!stored()) values.forEach(shell::writeUser)
|
||||
stored()
|
||||
} catch (e: Win32Exception) {
|
||||
Log.w(TAG, "link scheme: cannot register $what in the registry: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private object SystemWindowsShell : WindowsShell {
|
||||
override fun readUser(key: String, name: String): String? = registryString(HKEY_CURRENT_USER, key, name)
|
||||
|
||||
override fun readMachine(key: String, name: String): String? = registryString(HKEY_LOCAL_MACHINE, key, name)
|
||||
|
||||
override fun writeUser(value: RegistryValue) {
|
||||
Advapi32Util.registryCreateKey(HKEY_CURRENT_USER, value.key)
|
||||
Advapi32Util.registrySetStringValue(HKEY_CURRENT_USER, value.key, value.name, value.value)
|
||||
}
|
||||
|
||||
// jna-platform 5.14 binds neither AssocQueryStringW nor SHChangeNotify.
|
||||
override fun linkHandler(scheme: String): String? =
|
||||
try {
|
||||
val assocQueryString = NativeLibrary.getInstance("shlwapi").getFunction("AssocQueryStringW")
|
||||
val length = IntByReference(0)
|
||||
// with no buffer the call reports the length it needs
|
||||
val sized = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, null, length))
|
||||
if (sized != S_FALSE) {
|
||||
null
|
||||
} else {
|
||||
val out = CharArray(length.value)
|
||||
val result = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, out, length))
|
||||
if (result == S_OK) String(out).substringBefore('\u0000') else null
|
||||
}
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, "link scheme: cannot query the $scheme: handler: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
override fun notifyAssociationsChanged() {
|
||||
try {
|
||||
NativeLibrary.getInstance("shell32").getFunction("SHChangeNotify").invokeVoid(arrayOf(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, null, null))
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, "link scheme: cannot announce changed associations: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registryGetStringValue throws a RuntimeException for a value of another type, which another program could write.
|
||||
private fun registryString(hive: HKEY, key: String, name: String): String? =
|
||||
if (Advapi32Util.registryValueExists(hive, key, name)) {
|
||||
Advapi32Util.registryGetValue(hive, key, name) as? String
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Per the desktop entry spec, quoting escapes " ` $ \ and the string escape then doubles every backslash.
|
||||
// xdg-open 1.1.3 cannot run a quoted program, so quotes are added only when required.
|
||||
internal fun desktopExecArgument(arg: String): String {
|
||||
if (arg.none { it in EXEC_RESERVED_CHARACTERS }) return arg
|
||||
val quoted = arg.replace("\\", "\\\\").replace("\"", "\\\"").replace("`", "\\`").replace("$", "\\$")
|
||||
return "\"" + quoted.replace("\\", "\\\\") + "\""
|
||||
}
|
||||
|
||||
internal fun appImageDesktopEntry(appImagePath: String): String? {
|
||||
// A line break would end the Exec value and start a key of the path's choosing.
|
||||
// GLib looks the program up before it expands %%, so no escaping lets it find a path containing %.
|
||||
if (appImagePath.any { it == '\n' || it == '\r' || it == '%' }) return null
|
||||
return """
|
||||
|[Desktop Entry]
|
||||
|Type=Application
|
||||
|Name=$APP_DISPLAY_NAME
|
||||
|NoDisplay=true
|
||||
|Exec=${desktopExecArgument(appImagePath)} %u
|
||||
|MimeType=$APP_LINK_MIME_TYPE;$CONNECTION_LINK_MIME_TYPE;
|
||||
|""".trimMargin()
|
||||
}
|
||||
|
||||
// The entry runs this file for every link, so neither it nor any directory above it may be replaceable by another user,
|
||||
// including the user's own directory inside /tmp, which can be cleaned away and recreated by anyone.
|
||||
internal fun onlyOwnerCanReplace(path: Path, user: String): Boolean =
|
||||
try {
|
||||
generateSequence(path) { it.parent }.all { onlyOwnerCanWrite(Files.readAttributes(it, PosixFileAttributes::class.java), user) }
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
// A group named after the user is a user private group, which umask 002 systems make group-writable.
|
||||
internal fun onlyOwnerCanWrite(attributes: PosixFileAttributes, user: String): Boolean {
|
||||
val permissions = attributes.permissions()
|
||||
return attributes.owner().name in setOf(user, ROOT_USER) &&
|
||||
PosixFilePermission.OTHERS_WRITE !in permissions &&
|
||||
(PosixFilePermission.GROUP_WRITE !in permissions || attributes.group().name == user)
|
||||
}
|
||||
|
||||
internal fun registerAppImageScheme(appImagePath: String, applicationsDir: File, systemApplicationsDirs: List<File>, run: (List<String>) -> String?): Boolean {
|
||||
if (!onlyOwnerCanReplace(Path.of(appImagePath), System.getProperty("user.name"))) {
|
||||
Log.w(TAG, "link scheme: not registered, as other users may replace $appImagePath")
|
||||
return false
|
||||
}
|
||||
val content = appImageDesktopEntry(appImagePath) ?: return false
|
||||
val entry = File(applicationsDir, APPIMAGE_ENTRY_NAME)
|
||||
try {
|
||||
if (!entry.isFile || entry.readText() != content) {
|
||||
applicationsDir.mkdirs()
|
||||
entry.writeText(content)
|
||||
run(listOf("update-desktop-database", applicationsDir.path))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "link scheme: cannot write desktop entry: ${e.message}")
|
||||
return false
|
||||
}
|
||||
val entryDirs = listOf(applicationsDir) + systemApplicationsDirs
|
||||
val registered = makeAppImageEntryDefault(APP_LINK_MIME_TYPE, entryDirs, run)
|
||||
makeAppImageEntryDefault(CONNECTION_LINK_MIME_TYPE, entryDirs, run)
|
||||
return registered
|
||||
}
|
||||
|
||||
private fun makeAppImageEntryDefault(mimeType: String, entryDirs: List<File>, run: (List<String>) -> String?): Boolean {
|
||||
val current = xdgDefaultSchemeHandler(mimeType, run)
|
||||
if (current == APPIMAGE_ENTRY_NAME) return true
|
||||
// another installed handler keeps the default; only a missing one is replaced
|
||||
if (current != null && entryDirs.any { File(it, current).isFile }) return false
|
||||
run(listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType))
|
||||
return xdgDefaultSchemeHandler(mimeType, run) == APPIMAGE_ENTRY_NAME
|
||||
}
|
||||
|
||||
private fun xdgDefaultSchemeHandler(mimeType: String, run: (List<String>) -> String?): String? =
|
||||
run(listOf(XDG_MIME, "query", "default", mimeType))?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun systemApplicationsDirs(): List<File> =
|
||||
(System.getenv("XDG_DATA_DIRS")?.takeIf { it.isNotEmpty() } ?: DEFAULT_XDG_DATA_DIRS).split(File.pathSeparator).map { File(it, APPLICATIONS_DIR) }
|
||||
|
||||
internal fun linuxPackageRegistered(run: (List<String>) -> String?): Boolean =
|
||||
xdgDefaultSchemeHandler(APP_LINK_MIME_TYPE, run) == DEB_ENTRY_NAME
|
||||
|
||||
internal fun runProcess(command: List<String>): String? {
|
||||
val process = try {
|
||||
ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.DISCARD).start()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "link scheme: cannot run ${command.first()}: ${e.message}")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
process.outputStream.close()
|
||||
if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
Log.w(TAG, "link scheme: ${command.first()} timed out")
|
||||
null
|
||||
} else if (process.exitValue() == 0) {
|
||||
process.inputStream.bufferedReader().readText()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "link scheme: ${command.first()} failed: ${e.message}")
|
||||
null
|
||||
} catch (_: InterruptedException) {
|
||||
process.destroyForcibly()
|
||||
Thread.currentThread().interrupt()
|
||||
null
|
||||
} finally {
|
||||
process.inputStream.close()
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -5,8 +5,10 @@ import java.util.*
|
||||
|
||||
private val home = System.getProperty("user.home")
|
||||
private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config") + "/simplex"
|
||||
private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex"
|
||||
val desktopPlatform = detectDesktopPlatform()
|
||||
internal val unixDataHome = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"
|
||||
private val unixDataPath = "$unixDataHome/simplex"
|
||||
// DesktopPlatform's constants read this file's paths, so an eager value is null when the enum initializes first.
|
||||
val desktopPlatform by lazy { detectDesktopPlatform() }
|
||||
|
||||
enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) {
|
||||
LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package chat.simplex.app
|
||||
|
||||
internal const val BADGE_LINK_PREFIX = "simplexchat:/badge/code/"
|
||||
internal const val BADGE_LINK = "${BADGE_LINK_PREFIX}SB0000000000000000000"
|
||||
internal const val OTHER_BADGE_LINK = "${BADGE_LINK_PREFIX}SB1111111111111111111"
|
||||
|
||||
internal fun badgeLinkOfBytes(size: Int): String = BADGE_LINK_PREFIX + "A".repeat(size - BADGE_LINK_PREFIX.length)
|
||||
|
||||
internal const val SHORT_CONNECTION_LINK = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
|
||||
private const val ONE_TIME_LINK_PREFIX = "simplex:/invitation#/?v=2-7&smp=smp%3A%2F%2Fexample&e2e=v%3D2-3%26kem_key%3D"
|
||||
// an sntrup761 public key is 1158 bytes, 1544 characters in base64url
|
||||
private const val KEM_KEY_CHARACTERS = 1544
|
||||
|
||||
internal fun oneTimeLinkWithKemKey(): String = ONE_TIME_LINK_PREFIX + "A".repeat(KEM_KEY_CHARACTERS)
|
||||
+680
@@ -0,0 +1,680 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.platform.APPIMAGE_ENTRY_NAME
|
||||
import chat.simplex.common.platform.DesktopInstallation
|
||||
import chat.simplex.common.platform.DesktopPlatform
|
||||
import chat.simplex.common.platform.RegistryValue
|
||||
import chat.simplex.common.platform.APP_LINK_MIME_TYPE
|
||||
import chat.simplex.common.platform.CONNECTION_LINK_MIME_TYPE
|
||||
import chat.simplex.common.platform.appImageDesktopEntry
|
||||
import chat.simplex.common.platform.desktopExecArgument
|
||||
import chat.simplex.common.platform.desktopInstallation
|
||||
import chat.simplex.common.platform.linuxPackageRegistered
|
||||
import chat.simplex.common.platform.onlyOwnerCanReplace
|
||||
import chat.simplex.common.platform.onlyOwnerCanWrite
|
||||
import chat.simplex.common.platform.registerAppImageScheme
|
||||
import chat.simplex.common.platform.registerWindowsSchemes
|
||||
import chat.simplex.common.platform.runProcess
|
||||
import chat.simplex.common.platform.windowsCommandProgram
|
||||
import chat.simplex.common.platform.windowsApplicationValues
|
||||
import chat.simplex.common.platform.windowsOpenCommand
|
||||
import chat.simplex.common.platform.WindowsShell
|
||||
import chat.simplex.common.platform.windowsProgIdValues
|
||||
import chat.simplex.common.platform.windowsSchemeValues
|
||||
import com.sun.jna.platform.win32.Win32Exception
|
||||
import com.sun.jna.platform.win32.WinError
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import org.junit.Assume.assumeNoException
|
||||
import org.junit.Assume.assumeTrue
|
||||
import java.io.File
|
||||
import java.lang.reflect.Proxy
|
||||
import java.nio.file.FileSystemException
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.GroupPrincipal
|
||||
import java.nio.file.attribute.PosixFileAttributes
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.nio.file.attribute.UserPrincipal
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
private const val OWNER_WRITABLE = "rwxr-xr-x"
|
||||
private const val GROUP_WRITABLE = "rwxrwxr-x"
|
||||
private const val OTHERS_WRITABLE = "rwxr-xrwx"
|
||||
private const val WORLD_WRITABLE = "rwxrwxrwx"
|
||||
private const val APPIMAGE_FILE_NAME = "SimpleX.AppImage"
|
||||
private const val XDG_MIME = "xdg-mime"
|
||||
|
||||
class AppLinkSchemeTest {
|
||||
private val windowsExe = "C:\\Program Files\\SimpleX\\SimpleX.exe"
|
||||
private val appLinkCommandKey = "Software\\Classes\\simplexchat\\shell\\open\\command"
|
||||
private val connectionCommandKey = "Software\\Classes\\simplex\\shell\\open\\command"
|
||||
private val appLinkCommand = appLinkCommandKey to ""
|
||||
private val connectionCommand = connectionCommandKey to ""
|
||||
private val olderWindowsCommand = "\"C:\\Old\\SimpleX.exe\" \"%1\""
|
||||
private val appImagePath = "/home/user/$APPIMAGE_FILE_NAME"
|
||||
|
||||
@Test
|
||||
fun windowsCommandQuotesTheAppAndTheLink() {
|
||||
assertEquals(
|
||||
"\"C:\\Program Files\\SimpleX\\SimpleX.exe\" \"%1\"",
|
||||
windowsOpenCommand(windowsExe)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsSchemeIsAUrlProtocolNamingItsProgId() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
RegistryValue(key = "Software\\Classes\\simplexchat", name = "", value = "URL:SimpleX Chat"),
|
||||
RegistryValue(key = "Software\\Classes\\simplexchat", name = "URL Protocol", value = ""),
|
||||
RegistryValue(key = "Software\\Classes\\simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"),
|
||||
RegistryValue(key = appLinkCommandKey, name = "", value = "\"$windowsExe\" \"%1\""),
|
||||
RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplexchat", value = "SimpleX.simplexchat"),
|
||||
),
|
||||
windowsSchemeValues("simplexchat", windowsExe),
|
||||
"app link scheme"
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
RegistryValue(key = "Software\\Classes\\simplex", name = "", value = "URL:SimpleX Chat"),
|
||||
RegistryValue(key = "Software\\Classes\\simplex", name = "URL Protocol", value = ""),
|
||||
RegistryValue(key = "Software\\Classes\\simplex\\DefaultIcon", name = "", value = "\"$windowsExe\",0"),
|
||||
RegistryValue(key = connectionCommandKey, name = "", value = "\"$windowsExe\" \"%1\""),
|
||||
RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplex", value = "SimpleX.simplex"),
|
||||
),
|
||||
windowsSchemeValues("simplex", windowsExe),
|
||||
"connection link scheme"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsProgIdOpensTheExe() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat", name = "", value = "URL:SimpleX Chat"),
|
||||
RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"),
|
||||
RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\shell\\open\\command", name = "", value = "\"$windowsExe\" \"%1\""),
|
||||
),
|
||||
windowsProgIdValues("simplexchat", windowsExe)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsApplicationIsListedInDefaultAppsAfterItsCapabilities() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
RegistryValue(key = "Software\\chat.simplex.app\\Capabilities", name = "ApplicationName", value = "SimpleX Chat"),
|
||||
RegistryValue(
|
||||
key = "Software\\chat.simplex.app\\Capabilities",
|
||||
name = "ApplicationDescription",
|
||||
value = "Private and secure open-source messenger - no user IDs (not even random numbers)"
|
||||
),
|
||||
RegistryValue(key = "Software\\RegisteredApplications", name = "SimpleX Chat", value = "Software\\chat.simplex.app\\Capabilities"),
|
||||
),
|
||||
windowsApplicationValues
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationWritesEveryValueWhenTheCommandsPointAtAMissingProgram() {
|
||||
val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to olderWindowsCommand, connectionCommand to olderWindowsCommand))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(fullRegistration(), shell.writes)
|
||||
assertTrue(shell.notified, "Windows is told that associations changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsCommandProgramIsTheQuotedOrFirstArgument() {
|
||||
assertEquals("C:\\Program Files\\Other\\Other.exe", windowsCommandProgram("\"C:\\Program Files\\Other\\Other.exe\" \"%1\""), "quoted")
|
||||
assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe %1"), "unquoted")
|
||||
assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe"), "no arguments")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationLeavesAFullRegistrationAlone() {
|
||||
val shell = FakeWindowsShell(user = registered())
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(listOf(), shell.writes, "nothing is written again")
|
||||
assertFalse(shell.notified, "an unchanged registration does not clear Explorer's caches")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationRepairsAValueMissingBesideAMatchingCommand() {
|
||||
val shell = FakeWindowsShell(user = registered().apply { remove("Software\\Classes\\simplexchat" to "URL Protocol") })
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(windowsSchemeValues("simplexchat", windowsExe), shell.writes, "only the incomplete scheme is rewritten")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationAnnouncesARepairedApplicationListing() {
|
||||
val shell = FakeWindowsShell(user = registered().apply { remove("Software\\RegisteredApplications" to "SimpleX Chat") })
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(windowsApplicationValues, shell.writes, "only the application listing is rewritten")
|
||||
assertTrue(shell.notified, "Windows is told that associations changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationKeepsAnotherInstalledHandler() = withTempDir { dir ->
|
||||
val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\""
|
||||
val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to otherCommand, connectionCommand to otherCommand))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(
|
||||
windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe),
|
||||
shell.writes,
|
||||
"only this app's own ProgIds are written; no scheme is taken and no application is listed without one"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationKeepsAMachineWideHandler() = withTempDir { dir ->
|
||||
val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\""
|
||||
val shell = FakeWindowsShell(machine = mutableMapOf(appLinkCommand to otherCommand))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertTrue(shell.writes.none { it.key == appLinkCommandKey }, "a per-user key would hide the machine-wide handler")
|
||||
assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the free scheme is taken")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationRepairsItsProgIdWhileAnotherProgramHoldsTheScheme() = withTempDir { dir ->
|
||||
val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\""
|
||||
val staleProgId = windowsProgIdValues("simplexchat", "C:\\Old\\SimpleX.exe").associate { (it.key to it.name) to it.value }
|
||||
val shell = FakeWindowsShell(user = (staleProgId + (appLinkCommand to otherCommand)).toMutableMap())
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(windowsProgIdValues("simplexchat", windowsExe), shell.writes.take(3), "the ProgId points at this exe again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationReportsTheProgramTheShellStarts() {
|
||||
assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = "C:\\Other\\Other.exe")), "another app picked in settings")
|
||||
assertTrue(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = windowsExe.uppercase())), "paths differ only in case")
|
||||
assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = null)), "the shell has no answer")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationContinuesPastARegistryError() {
|
||||
val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\simplexchat"))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme could not be written")
|
||||
assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the connection link scheme is still written")
|
||||
assertTrue(windowsApplicationValues.all { it in shell.writes }, "the application is listed for the scheme that is ours")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationTakesNoSchemeWhoseHandlerCannotBeRead() {
|
||||
val shell = FakeWindowsShell(failingReads = setOf(appLinkCommandKey, connectionCommandKey))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertEquals(
|
||||
windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe),
|
||||
shell.writes,
|
||||
"an unreadable handler is left alone, and no application is listed without a scheme"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationTakesNoSchemeWhoseMachineWideHandlerCannotBeRead() {
|
||||
val shell = FakeWindowsShell(failingMachineReads = setOf(appLinkCommandKey))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme is left alone")
|
||||
assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the readable scheme is taken")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationTakesNoSchemeWhoseProgIdCannotBeWritten() {
|
||||
val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\SimpleX.simplexchat"))
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "URLAssociations must not name a missing ProgId")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationKeepsAnotherInstalledCopysProgId() = withTempDir { dir ->
|
||||
val otherCopy = Files.createFile(dir.resolve("SimpleX.exe")).toString()
|
||||
val otherProgId = windowsProgIdValues("simplexchat", otherCopy).associate { (it.key to it.name) to it.value }
|
||||
val shell = FakeWindowsShell(user = otherProgId.toMutableMap())
|
||||
registerWindowsSchemes(windowsExe, shell)
|
||||
assertTrue(shell.writes.none { it.key.contains("simplexchat") }, "links picked for the other copy keep reaching it: neither its ProgId nor the scheme is taken")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsRegistrationTreatsItsOwnProgramAsItsOwnHandler() = withTempDir { dir ->
|
||||
val exe = Files.createFile(dir.resolve("SimpleX.exe")).toString()
|
||||
val otherExe = Files.createFile(dir.resolve("Other.exe")).toString()
|
||||
val shell = FakeWindowsShell(
|
||||
user = mutableMapOf(appLinkCommand to "\"$exe\" %1"),
|
||||
machine = mutableMapOf(appLinkCommand to "\"$otherExe\" \"%1\""),
|
||||
)
|
||||
registerWindowsSchemes(exe, shell)
|
||||
assertTrue(windowsSchemeValues("simplexchat", exe).all { it in shell.writes }, "a command for this exe is updated, and the hidden machine-wide one is ignored")
|
||||
}
|
||||
|
||||
private fun fullRegistration(): List<RegistryValue> =
|
||||
windowsProgIdValues("simplexchat", windowsExe) + windowsSchemeValues("simplexchat", windowsExe) +
|
||||
windowsProgIdValues("simplex", windowsExe) + windowsSchemeValues("simplex", windowsExe) + windowsApplicationValues
|
||||
|
||||
private fun registered(): MutableMap<Pair<String, String>, String> =
|
||||
fullRegistration().associate { (it.key to it.name) to it.value }.toMutableMap()
|
||||
|
||||
private inner class FakeWindowsShell(
|
||||
private val user: MutableMap<Pair<String, String>, String> = mutableMapOf(),
|
||||
private val machine: Map<Pair<String, String>, String> = mapOf(),
|
||||
private val handler: String? = windowsExe,
|
||||
private val failingReads: Set<String> = setOf(),
|
||||
private val failingMachineReads: Set<String> = setOf(),
|
||||
private val failingWrites: Set<String> = setOf(),
|
||||
) : WindowsShell {
|
||||
val writes = mutableListOf<RegistryValue>()
|
||||
var notified = false
|
||||
|
||||
override fun readUser(key: String, name: String): String? {
|
||||
if (key in failingReads) throw AccessDenied()
|
||||
return user[key to name]
|
||||
}
|
||||
|
||||
override fun readMachine(key: String, name: String): String? {
|
||||
if (key in failingMachineReads) throw AccessDenied()
|
||||
return machine[key to name]
|
||||
}
|
||||
|
||||
override fun writeUser(value: RegistryValue) {
|
||||
if (value.key in failingWrites) throw AccessDenied()
|
||||
writes += value
|
||||
user[value.key to value.name] = value.value
|
||||
}
|
||||
|
||||
override fun linkHandler(scheme: String): String? = handler
|
||||
|
||||
override fun notifyAssociationsChanged() {
|
||||
notified = true
|
||||
}
|
||||
}
|
||||
|
||||
// Win32Exception's public constructors format the message through kernel32, which only Windows has.
|
||||
private class AccessDenied : Win32Exception(WinError.ERROR_ACCESS_DENIED, WinNT.HRESULT(WinError.ERROR_ACCESS_DENIED), "access denied")
|
||||
|
||||
@Test
|
||||
fun execArgumentOfAPlainPathIsLeftBare() {
|
||||
assertEquals("/home/user/Downloads/simplex-desktop-x86_64.AppImage", desktopExecArgument("/home/user/Downloads/simplex-desktop-x86_64.AppImage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execArgumentIsQuotedForEveryReservedCharacter() {
|
||||
for (c in " \t\n\"'\\><~|&;$*?#()`") {
|
||||
val arg = desktopExecArgument("/a${c}b")
|
||||
assertTrue(arg.startsWith("\"") && arg.endsWith("\""), "reserved character ${c.code} must be quoted, got $arg")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execArgumentKeepsSpacesInsideTheQuotes() {
|
||||
assertEquals("\"/home/user/My Apps/simplex.AppImage\"", desktopExecArgument("/home/user/My Apps/simplex.AppImage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execArgumentEscapesReservedCharacters() {
|
||||
assertEquals("\"/a\\\\\"b\"", desktopExecArgument("/a\"b"), "double quote")
|
||||
assertEquals("\"/a\\\\`b\"", desktopExecArgument("/a`b"), "backtick")
|
||||
assertEquals("\"/a\\\\\$b\"", desktopExecArgument("/a\$b"), "dollar")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execArgumentTurnsABackslashIntoFour() {
|
||||
assertEquals("\"/a\\\\\\\\b\"", desktopExecArgument("/a\\b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageEntryHandlesTheSchemeAndStaysOutOfMenus() {
|
||||
assertEquals(
|
||||
"""
|
||||
|[Desktop Entry]
|
||||
|Type=Application
|
||||
|Name=SimpleX Chat
|
||||
|NoDisplay=true
|
||||
|Exec="/home/user/My Apps/simplex.AppImage" %u
|
||||
|MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;
|
||||
|""".trimMargin(),
|
||||
appImageDesktopEntry("/home/user/My Apps/simplex.AppImage")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageEntryIsRefusedForAPathItCannotRun() {
|
||||
assertNull(appImageDesktopEntry("/a\nExec=evil"), "line feed")
|
||||
assertNull(appImageDesktopEntry("/a\rb"), "carriage return")
|
||||
assertNull(appImageDesktopEntry("/a%1.AppImage"), "percent")
|
||||
}
|
||||
|
||||
private val linux = DesktopPlatform.LINUX_X86_64
|
||||
private val linuxAppPath = "/opt/simplex/bin/simplex"
|
||||
|
||||
@Test
|
||||
fun unpackagedRunHasNoInstallation() {
|
||||
assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(linux, null, mapOf()), "linux")
|
||||
assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.MAC_AARCH64, null, mapOf()), "macOS")
|
||||
assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.WINDOWS_X86_64, null, mapOf()), "windows")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packagedMacAndWindowsAreRecognised() {
|
||||
assertEquals(DesktopInstallation.MacBundle, desktopInstallation(DesktopPlatform.MAC_AARCH64, "/Applications/SimpleX.app/Contents/MacOS/SimpleX", mapOf()))
|
||||
assertEquals(DesktopInstallation.WindowsExe(windowsExe), desktopInstallation(DesktopPlatform.WINDOWS_X86_64, windowsExe, mapOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flatpakWinsWhenTheAppImageCheckWouldAlsoMatch() {
|
||||
val env = mapOf("container" to "flatpak", "APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_a")
|
||||
assertEquals(DesktopInstallation.Flatpak, desktopInstallation(linux, "/tmp/.mount_a/usr/bin/simplex", env))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageIsRecognisedWhenTheLauncherRunsFromItsMount() {
|
||||
val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to "/tmp/.mount_Simple")
|
||||
assertEquals(
|
||||
DesktopInstallation.AppImage(appImagePath),
|
||||
desktopInstallation(linux, "/tmp/.mount_Simple/usr/bin/simplex", env)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageIsRecognisedWhenItsMountIsReachedThroughASymlink() = withTempDir { tmp ->
|
||||
val mount = Files.createDirectories(tmp.resolve("real/.mount_Simple/usr/bin"))
|
||||
val linkedTmp = try {
|
||||
Files.createSymbolicLink(tmp.resolve("linked"), tmp.resolve("real"))
|
||||
} catch (e: FileSystemException) {
|
||||
assumeNoException("symbolic links cannot be created here", e)
|
||||
return@withTempDir
|
||||
}
|
||||
val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to linkedTmp.resolve(".mount_Simple").toString())
|
||||
assertEquals(
|
||||
DesktopInstallation.AppImage(appImagePath),
|
||||
desktopInstallation(linux, mount.toRealPath().resolve("simplex").toString(), env)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inheritedAppImageVariablesDoNotMakeTheDebAnAppImage() {
|
||||
val inherited = mapOf("APPIMAGE" to "/home/user/Terminal.AppImage", "APPDIR" to "/tmp/.mount_Termin")
|
||||
assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, inherited), "another AppImage's mount")
|
||||
assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, mapOf("APPIMAGE" to "/home/user/a.AppImage")), "no APPDIR")
|
||||
val prefix = mapOf("APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_ab")
|
||||
assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, "/tmp/.mount_abc/usr/bin/simplex", prefix), "mount path that only shares a prefix")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileOnlyItsOwnerCanWriteIsAccepted() {
|
||||
assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", OWNER_WRITABLE), user = "alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootOwnedFileIsAccepted() {
|
||||
assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "root", OWNER_WRITABLE), user = "alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileOfAnotherOwnerIsRefused() {
|
||||
assertFalse(onlyOwnerCanWrite(attributes(owner = "bob", group = "staff", OWNER_WRITABLE), user = "alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worldWritableFileIsRefused() {
|
||||
assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", OTHERS_WRITABLE), user = "alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupWritableFileIsRefusedForASharedGroup() {
|
||||
assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", GROUP_WRITABLE), user = "alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupWritableFileIsAcceptedForTheUsersOwnGroup() {
|
||||
assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by the user")
|
||||
assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by root")
|
||||
assertFalse(onlyOwnerCanWrite(attributes(owner = "root", group = "root", GROUP_WRITABLE), user = "alice"), "root's own group")
|
||||
}
|
||||
|
||||
private fun attributes(owner: String, group: String, permissions: String): PosixFileAttributes =
|
||||
Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PosixFileAttributes::class.java)) { _, method, _ ->
|
||||
when (method.name) {
|
||||
"owner" -> UserPrincipal { owner }
|
||||
"group" -> GroupPrincipal { group }
|
||||
"permissions" -> PosixFilePermissions.fromString(permissions)
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as PosixFileAttributes
|
||||
|
||||
@Test
|
||||
fun appImageOnlyItsOwnerCanWriteIsAccepted() = withAppImage {
|
||||
assertTrue(onlyOwnerCanReplace(it, Files.getOwner(it).name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageInAWorldWritableDirectoryIsRefused() = withAppImage(directoryPermissions = WORLD_WRITABLE) {
|
||||
assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "the directory must be checked as well as the file")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageUnderAWorldWritableAncestorIsRefused() = withAppImage(ancestorPermissions = WORLD_WRITABLE) {
|
||||
assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "every directory up to the root must be checked")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worldWritableAppImageIsRefused() = withAppImage(filePermissions = OTHERS_WRITABLE) {
|
||||
assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageOfAnotherOwnerIsRefused() = withAppImage {
|
||||
val owner = Files.getOwner(it).name
|
||||
assumeTrue("root is accepted as an owner", owner != "root")
|
||||
assertFalse(onlyOwnerCanReplace(it, "$owner-other"), "a file owned by $owner checked for $owner-other")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingAppImageIsRefused() {
|
||||
assumePosix()
|
||||
assertFalse(onlyOwnerCanReplace(Path.of("/nonexistent-simplex-dir/SimpleX.AppImage"), "root"))
|
||||
}
|
||||
|
||||
// Built under the module's build directory, as the check walks up to the root and the system temp directory is open to everyone.
|
||||
private fun withAppImage(
|
||||
directoryPermissions: String = OWNER_WRITABLE,
|
||||
filePermissions: String = OWNER_WRITABLE,
|
||||
ancestorPermissions: String = OWNER_WRITABLE,
|
||||
fileName: String = APPIMAGE_FILE_NAME,
|
||||
check: (Path) -> Unit
|
||||
) {
|
||||
assumePosix()
|
||||
val base = Path.of("build").toAbsolutePath()
|
||||
val user = System.getProperty("user.name")
|
||||
assumeTrue(
|
||||
"$base and every directory above it must be owned by $user or root and closed to others",
|
||||
Files.isDirectory(base) && generateSequence(base) { it.parent }.all {
|
||||
val attributes = Files.readAttributes(it, PosixFileAttributes::class.java)
|
||||
attributes.owner().name in setOf(user, "root") &&
|
||||
PosixFilePermission.OTHERS_WRITE !in attributes.permissions() &&
|
||||
(PosixFilePermission.GROUP_WRITE !in attributes.permissions() || attributes.group().name == user)
|
||||
}
|
||||
)
|
||||
withTempDir(base) { tmp ->
|
||||
val ancestor = Files.createDirectory(tmp.resolve("shared"))
|
||||
val dir = Files.createDirectory(ancestor.resolve("apps"))
|
||||
val appImage = Files.createFile(dir.resolve(fileName))
|
||||
Files.setPosixFilePermissions(appImage, PosixFilePermissions.fromString(filePermissions))
|
||||
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString(directoryPermissions))
|
||||
Files.setPosixFilePermissions(ancestor, PosixFilePermissions.fromString(ancestorPermissions))
|
||||
check(appImage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assumePosix() =
|
||||
assumeTrue("needs POSIX permissions", "posix" in FileSystems.getDefault().supportedFileAttributeViews())
|
||||
|
||||
@Test
|
||||
fun runProcessReturnsTheOutputOfASuccessfulRun() {
|
||||
assumeShell()
|
||||
assertEquals("x\n", runProcess(listOf("sh", "-c", "echo x")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runProcessKeepsErrorOutputOutOfTheResult() {
|
||||
assumeShell()
|
||||
// more than a pipe holds, so error output left in a pipe would block the process until the timeout
|
||||
assertEquals("x\n", runProcess(listOf("sh", "-c", "head -c 100000 /dev/zero >&2; echo x")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runProcessGivesTheProcessAnEndOfInput() {
|
||||
assumeShell()
|
||||
assertEquals("x\n", runProcess(listOf("sh", "-c", "cat >/dev/null; echo x")), "a process reading its input must not wait for the timeout")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runProcessReturnsNothingForAFailedRun() {
|
||||
assumeShell()
|
||||
assertNull(runProcess(listOf("sh", "-c", "echo x; exit 3")))
|
||||
}
|
||||
|
||||
private fun queryDefault(mimeType: String) = listOf(XDG_MIME, "query", "default", mimeType)
|
||||
private fun setDefault(mimeType: String) = listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType)
|
||||
private val queryBothDefaults = listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE))
|
||||
private val setBothDefaults = listOf(
|
||||
queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE),
|
||||
queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationWritesTheEntryAndMakesItTheDefault() = withRegistration { appImage, applicationsDir ->
|
||||
val xdg = FakeXdgMime(default = null)
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler")
|
||||
assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText(), "the missing directory is created")
|
||||
assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationLeavesAnUnchangedEntryThatIsAlreadyTheDefault() = withRegistration { appImage, applicationsDir ->
|
||||
applicationsDir.mkdirs()
|
||||
File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!)
|
||||
val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME)
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run))
|
||||
assertEquals(queryBothDefaults, xdg.calls, "nothing is rewritten or set again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationTakesTheDefaultFromAHandlerNoLongerInstalled() = withRegistration { appImage, applicationsDir ->
|
||||
applicationsDir.mkdirs()
|
||||
File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!)
|
||||
val xdg = FakeXdgMime(default = "other.desktop")
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler again")
|
||||
assertEquals(setBothDefaults, xdg.calls, "the entry is not rewritten, only made the default")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationSetsOnlyTheDefaultThatPointsElsewhere() = withRegistration { appImage, applicationsDir ->
|
||||
applicationsDir.mkdirs()
|
||||
File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!)
|
||||
val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME, connectionDefault = "other.desktop")
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run))
|
||||
assertEquals(
|
||||
listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)),
|
||||
xdg.calls
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationRewritesTheEntryOfAMovedAppImage() = withRegistration { appImage, applicationsDir ->
|
||||
applicationsDir.mkdirs()
|
||||
File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry("/old/SimpleX.AppImage")!!)
|
||||
val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME)
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run))
|
||||
assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText())
|
||||
assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationFailsWhenAnotherHandlerKeepsTheAppLinkDefault() = withRegistration { appImage, applicationsDir ->
|
||||
val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(APP_LINK_MIME_TYPE))
|
||||
assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the default handler did not change")
|
||||
assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls, "the connection link default is still set")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationSucceedsWhenAnotherHandlerKeepsOnlyTheConnectionDefault() = withRegistration { appImage, applicationsDir ->
|
||||
val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(CONNECTION_LINK_MIME_TYPE))
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "only simplexchat: decides the badge page")
|
||||
assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationKeepsAnotherInstalledHandler() = withRegistration { appImage, applicationsDir ->
|
||||
val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() }
|
||||
File(systemDir, "other.desktop").writeText("[Desktop Entry]\n")
|
||||
val xdg = FakeXdgMime(default = "other.desktop")
|
||||
assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "links stay with the installed handler")
|
||||
assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls, "the user's handler is not replaced")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationKeepsAnotherInstalledConnectionHandlerOnly() = withRegistration { appImage, applicationsDir ->
|
||||
val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() }
|
||||
File(systemDir, "other.desktop").writeText("[Desktop Entry]\n")
|
||||
val xdg = FakeXdgMime(default = null, connectionDefault = "other.desktop")
|
||||
assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "app links have no handler, so the entry takes them")
|
||||
assertEquals(
|
||||
listOf(
|
||||
updateDatabase(applicationsDir), queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE),
|
||||
queryDefault(CONNECTION_LINK_MIME_TYPE),
|
||||
),
|
||||
xdg.calls
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appImageRegistrationIsRefusedForAPathTheEntryCannotRun() =
|
||||
withRegistration(fileName = "Simple%X.AppImage") { appImage, applicationsDir ->
|
||||
val xdg = FakeXdgMime(default = null)
|
||||
assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "a path with % cannot be registered")
|
||||
assertEquals(listOf(), xdg.calls, "nothing is run")
|
||||
assertFalse(applicationsDir.exists(), "no entry is written")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linuxPackageCountsAsRegisteredOnlyWhenItsEntryIsTheDefault() {
|
||||
assertTrue(linuxPackageRegistered(FakeXdgMime(default = "simplex-simplex.desktop")::run), "the deb's own entry")
|
||||
assertFalse(linuxPackageRegistered(FakeXdgMime(default = "other.desktop")::run), "another handler")
|
||||
assertFalse(linuxPackageRegistered(FakeXdgMime(default = null)::run), "no handler")
|
||||
assertFalse(
|
||||
linuxPackageRegistered(FakeXdgMime(default = "other.desktop", connectionDefault = "simplex-simplex.desktop")::run),
|
||||
"only the connection link handler"
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateDatabase(applicationsDir: File) = listOf("update-desktop-database", applicationsDir.path)
|
||||
|
||||
private class FakeXdgMime(default: String?, connectionDefault: String? = default, private val keptMimeTypes: Set<String> = setOf()) {
|
||||
private val defaults = mutableMapOf(APP_LINK_MIME_TYPE to default, CONNECTION_LINK_MIME_TYPE to connectionDefault)
|
||||
val calls = mutableListOf<List<String>>()
|
||||
|
||||
fun run(command: List<String>): String? {
|
||||
calls += command
|
||||
if (command.take(3) == listOf(XDG_MIME, "query", "default")) return defaults[command[3]]?.let { "$it\n" }
|
||||
// xdg-mime sets the default only when given an entry and then a MIME type, the order it requires
|
||||
if (command.size == 4 && command[1] == "default" && command[2].endsWith(".desktop") && command[3] !in keptMimeTypes) {
|
||||
defaults[command[3]] = command[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun withRegistration(fileName: String = APPIMAGE_FILE_NAME, test: (appImage: String, applicationsDir: File) -> Unit) =
|
||||
withAppImage(fileName = fileName) { appImage ->
|
||||
assumeTrue("the AppImage must be owned by user.name", Files.getOwner(appImage).name == System.getProperty("user.name"))
|
||||
test(appImage.toString(), appImage.parent.parent.parent.resolve("share/applications").toFile())
|
||||
}
|
||||
|
||||
private fun assumeShell() =
|
||||
assumeTrue("needs sh", System.getenv("PATH")?.split(File.pathSeparator)?.any { Files.isExecutable(Path.of(it, "sh")) } == true)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.MAX_LINK_BYTES
|
||||
import chat.simplex.common.linkFromArgs
|
||||
import chat.simplex.common.isAcceptedLink
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AppLinksTest {
|
||||
@Test
|
||||
fun acceptsTheOnlyArgumentWhenItIsAnAppLink() {
|
||||
assertEquals(BADGE_LINK, linkFromArgs(arrayOf(BADGE_LINK)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsTheSchemeInAnyCase() {
|
||||
val link = "SimplexChat:/badge/code/SB0000000000000000000"
|
||||
assertEquals(link, linkFromArgs(arrayOf(link)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresNoArguments() {
|
||||
assertNull(linkFromArgs(arrayOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsAConnectionLink() {
|
||||
assertEquals(SHORT_CONNECTION_LINK, linkFromArgs(arrayOf(SHORT_CONNECTION_LINK)), "short link")
|
||||
val oneTimeLink = oneTimeLinkWithKemKey()
|
||||
assertEquals(oneTimeLink, linkFromArgs(arrayOf(oneTimeLink)), "one-time link of ${oneTimeLink.length} bytes with its post-quantum key")
|
||||
val upperCase = "SimpleX:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
|
||||
assertEquals(upperCase, linkFromArgs(arrayOf(upperCase)), "scheme in another case")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresWebAndOtherLinks() {
|
||||
assertNull(linkFromArgs(arrayOf("https://simplex.chat/contact#/?v=2-7")), "web link")
|
||||
assertNull(linkFromArgs(arrayOf("simplexfile:/x")), "another scheme sharing the prefix")
|
||||
assertNull(linkFromArgs(arrayOf("/home/user/simplex:/a")), "path")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresALinkThatIsNotTheOnlyArgument() {
|
||||
// a URL split by quotes in a handler command arrives as several arguments
|
||||
assertNull(linkFromArgs(arrayOf("--flag", BADGE_LINK)), "flag before link")
|
||||
assertNull(linkFromArgs(arrayOf(BADGE_LINK, "extra")), "argument after link")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresAnEmptyArgument() {
|
||||
assertNull(linkFromArgs(arrayOf("")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lengthBoundIsInclusive() {
|
||||
val atLimit = badgeLinkOfBytes(MAX_LINK_BYTES)
|
||||
val overLimit = atLimit + "A"
|
||||
assertTrue(isAcceptedLink(atLimit), "link of exactly $MAX_LINK_BYTES bytes")
|
||||
assertFalse(isAcceptedLink(overLimit), "link of ${overLimit.length} bytes")
|
||||
assertNull(linkFromArgs(arrayOf(overLimit)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lengthBoundCountsUtf8Bytes() {
|
||||
// The link has 4213 characters but 8413 bytes, so a bound counted in characters would accept it.
|
||||
assertNull(linkFromArgs(arrayOf("simplexchat:/" + "é".repeat(4200))))
|
||||
}
|
||||
}
|
||||
+273
-8
@@ -1,14 +1,48 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.FILE_TIME_TOLERANCE
|
||||
import chat.simplex.common.MAX_LINK_BYTES
|
||||
import chat.simplex.common.SHOW_FILE
|
||||
import chat.simplex.common.SHOW_TMP_SUFFIX
|
||||
import chat.simplex.common.ShowSignal
|
||||
import chat.simplex.common.TAKEN_SHOW_FILE
|
||||
import chat.simplex.common.deleteStaleSignalFiles
|
||||
import chat.simplex.common.signalRunningInstance
|
||||
import chat.simplex.common.startDespiteRunningInstance
|
||||
import chat.simplex.common.takeSignal
|
||||
import chat.simplex.common.watchShowSignals
|
||||
import org.junit.Assume.assumeFalse
|
||||
import org.junit.Assume.assumeTrue
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
import java.nio.file.FileSystemException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.StandardOpenOption.CREATE
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
import java.nio.file.StandardOpenOption.WRITE
|
||||
import java.nio.file.StandardWatchEventKinds
|
||||
import java.nio.file.attribute.FileTime
|
||||
import java.nio.file.attribute.PosixFileAttributeView
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
// generous for a loaded build machine; the watcher normally delivers within milliseconds
|
||||
private const val SIGNAL_WAIT_SECONDS = 15L
|
||||
// short, as the running instance in these tests never answers within the wait
|
||||
private const val RUNNING_INSTANCE_WAIT_MILLIS = 300L
|
||||
|
||||
class SingleInstanceTest {
|
||||
@Test
|
||||
@@ -43,14 +77,245 @@ class SingleInstanceTest {
|
||||
second.close()
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (java.nio.file.Path) -> Unit) {
|
||||
val tmp = Files.createTempDirectory("simplex-singleinstance-test")
|
||||
try {
|
||||
block(tmp)
|
||||
} finally {
|
||||
Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach {
|
||||
try { Files.delete(it) } catch (_: java.io.IOException) {}
|
||||
}
|
||||
@Test
|
||||
fun secondProcessExitsWhenTheRunningInstanceTakesTheSignal() = withTempDir { dir ->
|
||||
val taken = LinkedBlockingQueue<ShowSignal>()
|
||||
val runningInstance = thread {
|
||||
while (!Files.exists(dir.resolve(SHOW_FILE))) Thread.sleep(5)
|
||||
takeSignal(dir)?.let(taken::add)
|
||||
}
|
||||
val start = startDespiteRunningInstance(dir, BADGE_LINK, TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) {
|
||||
error("a responsive instance must not raise the alert")
|
||||
}
|
||||
runningInstance.join()
|
||||
assertFalse(start, "the second process must exit")
|
||||
assertEquals(BADGE_LINK, taken.poll()?.link, "the running instance opens the link")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondProcessExitsWhenTheUserDeclinesToStart() = withTempDir { dir ->
|
||||
assertFalse(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { false })
|
||||
assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the running instance to take once it recovers")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondProcessStartsAndWithdrawsTheSignalWhenTheUserStartsAnyway() = withTempDir { dir ->
|
||||
assertTrue(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { true })
|
||||
assertEquals(listOf(), fileNames(dir), "the withdrawn signal must not open the link in the running instance too")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondProcessExitsWhenTheRunningInstanceRecoversDuringTheAlert() = withTempDir { dir ->
|
||||
var opened: String? = null
|
||||
val start = startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) {
|
||||
opened = takeSignal(dir)?.link
|
||||
true
|
||||
}
|
||||
assertEquals(BADGE_LINK, opened, "the recovered instance opened the link")
|
||||
assertFalse(start, "starting anyway would open the same link a second time")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signalCarriesTheLinkAndIsTakenOnce() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
assertEquals(listOf(SHOW_FILE), fileNames(dir), "only the signal may exist after signalling")
|
||||
assertEquals(BADGE_LINK, takeSignal(dir)?.link)
|
||||
assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind")
|
||||
assertNull(takeSignal(dir), "a signal already taken must not be taken again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signalCarriesAOneTimeLinkWithItsPostQuantumKey() = withTempDir { dir ->
|
||||
val link = oneTimeLinkWithKemKey()
|
||||
signalRunningInstance(dir, link)
|
||||
assertEquals(link, takeSignal(dir)?.link, "a connection link of ${link.length} bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signalWithoutLinkIsTakenWithNoLink() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, null)
|
||||
val signal = takeSignal(dir)
|
||||
assertNotNull(signal, "an empty signal must still be taken")
|
||||
assertNull(signal.link, "an empty signal carries no link")
|
||||
assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind")
|
||||
assertNull(takeSignal(dir), "a signal already taken must not be taken again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun laterSignalReplacesAnUntakenOne() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
signalRunningInstance(dir, OTHER_BADGE_LINK)
|
||||
assertEquals(listOf(SHOW_FILE), fileNames(dir), "the later signal must replace the earlier one")
|
||||
assertEquals(OTHER_BADGE_LINK, takeSignal(dir)?.link)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signalIsReadableOnlyByItsOwner() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
val view = Files.getFileAttributeView(dir.resolve(SHOW_FILE), PosixFileAttributeView::class.java)
|
||||
assumeTrue("POSIX file permissions are not supported here", view != null)
|
||||
assertEquals(
|
||||
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
|
||||
view.readAttributes().permissions()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takeSignalReplacesATakenFileLeftByACrash() = withTempDir { dir ->
|
||||
Files.writeString(dir.resolve(TAKEN_SHOW_FILE), OTHER_BADGE_LINK)
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the new signal, not the leftover, must be read")
|
||||
assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takeSignalRetriesAMoveThatFailsOnce() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
var attempts = 0
|
||||
val signal = takeSignal(dir) { from, to ->
|
||||
attempts++
|
||||
if (attempts == 1) throw FileSystemException(from.toString(), null, "held open by another program")
|
||||
Files.move(from, to, StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
assertEquals(2, attempts, "one failed attempt and one retry")
|
||||
assertEquals(BADGE_LINK, signal?.link, "the retry must still deliver the link")
|
||||
assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takeSignalLeavesTheSignalWhenTheRetryFailsToo() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
var attempts = 0
|
||||
val signal = takeSignal(dir) { from, _ ->
|
||||
attempts++
|
||||
throw FileSystemException(from.toString(), null, "held open by another program")
|
||||
}
|
||||
assertEquals(2, attempts, "one failed attempt and one retry")
|
||||
assertNotNull(signal, "the window still comes forward")
|
||||
assertNull(signal.link, "a signal that cannot be read carries no link")
|
||||
assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the next attempt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takeSignalAcceptsALinkOfExactlyTheBound() = withTempDir { dir ->
|
||||
val atBound = badgeLinkOfBytes(MAX_LINK_BYTES)
|
||||
signalRunningInstance(dir, atBound)
|
||||
assertEquals(atBound, takeSignal(dir)?.link, "a link of exactly $MAX_LINK_BYTES bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takeSignalDropsContentThatIsNotAnAcceptedLink() = withTempDir { dir ->
|
||||
val show = dir.resolve(SHOW_FILE)
|
||||
fun assertTakenWithoutLink(case: String) {
|
||||
val signal = takeSignal(dir)
|
||||
assertNotNull(signal, "$case: rejected content is still a signal, so the window comes forward")
|
||||
assertNull(signal.link, case)
|
||||
}
|
||||
Files.writeString(show, "https://simplex.chat/contact#/?v=2-7")
|
||||
assertTakenWithoutLink("web link")
|
||||
assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed")
|
||||
|
||||
Files.writeString(show, badgeLinkOfBytes(MAX_LINK_BYTES + 1))
|
||||
assertTakenWithoutLink("link over the length bound")
|
||||
|
||||
// 4214 characters but 8414 bytes; a read cut at the bound would end on a whole character and decode to an accepted link
|
||||
Files.writeString(show, "simplexchat:/x" + "é".repeat(4200))
|
||||
assertTakenWithoutLink("multi-byte link over the byte bound")
|
||||
assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleSignalFilesRemovesOldTempAndTakenFilesOnly() = withTempDir { dir ->
|
||||
val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS)
|
||||
val old = FileTime.from(lockAttemptTime.minusSeconds(3600))
|
||||
Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex.started")), old)
|
||||
Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex_v1_chat.db")), old)
|
||||
Files.setLastModifiedTime(Files.createFile(dir.resolve("other.tmp")), old)
|
||||
Files.setLastModifiedTime(Files.createFile(dir.resolve("${SHOW_FILE}123$SHOW_TMP_SUFFIX")), old)
|
||||
Files.createFile(dir.resolve("${SHOW_FILE}456$SHOW_TMP_SUFFIX"))
|
||||
Files.createFile(dir.resolve(TAKEN_SHOW_FILE))
|
||||
Files.createFile(dir.resolve(SHOW_FILE))
|
||||
deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime))
|
||||
assertEquals(
|
||||
listOf("other.tmp", SHOW_FILE, "${SHOW_FILE}456$SHOW_TMP_SUFFIX", "simplex.started", "simplex_v1_chat.db"),
|
||||
fileNames(dir),
|
||||
"an old temp file and a taken file must go; a fresh temp file is a signal being written, and the lock and database stay"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleSignalFilesKeepsASignalWithinTheToleranceAndDropsAnOlderOne() = withTempDir { dir ->
|
||||
val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS)
|
||||
val show = dir.resolve(SHOW_FILE)
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE)))
|
||||
deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime))
|
||||
assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal exactly at the tolerance must be kept")
|
||||
|
||||
Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minusSeconds(1)))
|
||||
deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime))
|
||||
assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal one second behind the lock attempt must be kept")
|
||||
|
||||
Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE).minusSeconds(1)))
|
||||
deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime))
|
||||
assertEquals(listOf(), fileNames(dir), "a signal older than the tolerance must be deleted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watcherTakesASignalWrittenBeforeItsWatch() = withTempDir { dir ->
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
watch(dir) { signals ->
|
||||
assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "a signal written before the watch raised no event")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watcherTakesEverySignalArrivingWhileItWatches() = withTempDir { dir ->
|
||||
assumeWatchServiceReportsEachCreation(dir)
|
||||
watch(dir) { signals ->
|
||||
// Each signal is written after the previous one arrived, so later ones can come only from watch events.
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "first signal")
|
||||
signalRunningInstance(dir, OTHER_BADGE_LINK)
|
||||
assertEquals(OTHER_BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "second signal, seen as a creation")
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "third signal, after the watch key was reset")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signalIsWrittenThroughATempFileBesideIt() = withTempDir { dir ->
|
||||
// a temp file elsewhere may sit on another file system, where the atomic move fails and the link is dropped
|
||||
assumeWatchServiceReportsEachCreation(dir)
|
||||
val created = dir.fileSystem.newWatchService().use { ws ->
|
||||
dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE)
|
||||
signalRunningInstance(dir, BADGE_LINK)
|
||||
ws.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.pollEvents()?.map { it.context().toString() }.orEmpty()
|
||||
}
|
||||
assertTrue(
|
||||
created.any { it != TAKEN_SHOW_FILE && it.startsWith(SHOW_FILE) && it.endsWith(SHOW_TMP_SUFFIX) },
|
||||
"a temp file must be created in the signal directory, created: $created"
|
||||
)
|
||||
}
|
||||
|
||||
// macOS polls the directory and keeps a taken name cached, so it misses short-lived files and reports a reused name as a modification
|
||||
private fun assumeWatchServiceReportsEachCreation(dir: Path) =
|
||||
assumeFalse("needs a watch service that reports each creation", dir.fileSystem.newWatchService().use { it.javaClass.simpleName == "PollingWatchService" })
|
||||
|
||||
private fun watch(dir: Path, test: (signals: LinkedBlockingQueue<ShowSignal>) -> Unit) {
|
||||
val signals = LinkedBlockingQueue<ShowSignal>()
|
||||
val watcherThread = dir.fileSystem.newWatchService().use { ws ->
|
||||
dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE)
|
||||
thread(isDaemon = true) { watchShowSignals(ws, dir) { signals.put(it) } }.also {
|
||||
test(signals)
|
||||
// a second, duplicate delivery would arrive within this wait
|
||||
assertNull(signals.poll(1, TimeUnit.SECONDS), "each signal is taken once")
|
||||
}
|
||||
}
|
||||
watcherThread.join(TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS))
|
||||
assertFalse(watcherThread.isAlive, "closing the watch service ends the watcher")
|
||||
}
|
||||
|
||||
private fun fileNames(dir: Path): List<String> =
|
||||
Files.list(dir).use { files -> files.map { it.fileName.toString() }.sorted().toList() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
private const val TEMP_DIR_PREFIX = "simplex-desktop-test"
|
||||
|
||||
internal fun withTempDir(parent: Path? = null, block: (Path) -> Unit) {
|
||||
val tmp = if (parent == null) Files.createTempDirectory(TEMP_DIR_PREFIX) else Files.createTempDirectory(parent, TEMP_DIR_PREFIX)
|
||||
try {
|
||||
block(tmp)
|
||||
} finally {
|
||||
Files.walk(tmp).use { paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach {
|
||||
try { Files.delete(it) } catch (_: IOException) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.activationEvent
|
||||
import chat.simplex.common.sendActivation
|
||||
import com.sun.jna.NativeLong
|
||||
import com.sun.jna.platform.unix.X11
|
||||
import org.junit.Assume.assumeNoException
|
||||
import java.lang.reflect.Proxy
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
|
||||
private const val WINDOW_ID = 42L
|
||||
private const val ATOM_ID = 7L
|
||||
|
||||
class WindowActivationTest {
|
||||
@Test
|
||||
fun activationEventAsksTheWindowManagerToActivateTheWindowAsAPager() {
|
||||
assumeLibX11()
|
||||
val atom = X11.Atom(ATOM_ID)
|
||||
val event = activationEvent(X11.Window(WINDOW_ID), atom)
|
||||
val message = event.xclient
|
||||
assertEquals(WINDOW_ID, message.window.toLong(), "target window")
|
||||
assertEquals(atom, message.message_type, "_NET_ACTIVE_WINDOW atom")
|
||||
assertEquals(32, message.format, "data format in bits")
|
||||
// JNA writes only the selected member of a union, so the bytes XSendEvent receives are checked too
|
||||
event.write()
|
||||
assertEquals(X11.ClientMessage, event.pointer.getInt(0), "event type in native memory")
|
||||
assertEquals(2L, message.data.pointer.getNativeLong(0).toLong(), "source indication in native memory: a pager, which focus stealing prevention lets through")
|
||||
assertEquals(0L, message.data.pointer.getNativeLong(NativeLong.SIZE.toLong()).toLong(), "timestamp in native memory: CurrentTime")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activationIsSentToTheRootWindowAndTheDisplayIsClosed() {
|
||||
assumeLibX11()
|
||||
val x11 = FakeX11()
|
||||
sendActivation(x11.proxy, WINDOW_ID)
|
||||
assertEquals(listOf("XOpenDisplay", "XInternAtom", "XDefaultRootWindow", "XSendEvent", "XFlush", "XCloseDisplay"), x11.calls)
|
||||
assertNull(x11.displayName, "the display named by DISPLAY")
|
||||
assertEquals("_NET_ACTIVE_WINDOW", x11.atomName, "atom of the request")
|
||||
val send = x11.sendArgs
|
||||
assertSame(x11.display, send[0], "display")
|
||||
assertEquals(x11.root, send[1], "the request goes to the root window, where the window manager listens")
|
||||
assertEquals(NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), send[3], "event mask the window manager selects")
|
||||
val event = (send[4] as X11.XEvent).xclient
|
||||
assertEquals(WINDOW_ID, event.window.toLong(), "window to activate")
|
||||
assertEquals(x11.atom, event.message_type, "the interned atom is the request type")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayIsClosedWhenSendingFails() {
|
||||
assumeLibX11()
|
||||
val x11 = FakeX11(sendFails = true)
|
||||
assertFailsWith<IllegalStateException> { sendActivation(x11.proxy, WINDOW_ID) }
|
||||
assertEquals("XCloseDisplay", x11.calls.last(), "the display must be closed after a failure")
|
||||
}
|
||||
|
||||
private class FakeX11(private val sendFails: Boolean = false) {
|
||||
val display = X11.Display()
|
||||
val root = X11.Window(1)
|
||||
val atom = X11.Atom(ATOM_ID)
|
||||
val calls = mutableListOf<String>()
|
||||
var displayName: Any? = null
|
||||
var atomName: Any? = null
|
||||
var sendArgs: Array<Any?> = emptyArray()
|
||||
|
||||
val proxy = Proxy.newProxyInstance(X11::class.java.classLoader, arrayOf(X11::class.java)) { _, method, args ->
|
||||
calls += method.name
|
||||
when (method.name) {
|
||||
"XOpenDisplay" -> { displayName = args[0]; display }
|
||||
"XInternAtom" -> { atomName = args[1]; atom }
|
||||
"XDefaultRootWindow" -> root
|
||||
"XSendEvent" -> { sendArgs = args.copyOf(); if (sendFails) throw IllegalStateException("send failed"); 1 }
|
||||
"XFlush", "XCloseDisplay" -> 0
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as X11
|
||||
}
|
||||
|
||||
// JNA loads libX11 to lay out any X11 structure, and only X11 desktops have it
|
||||
private fun assumeLibX11() {
|
||||
try { X11.INSTANCE } catch (e: LinkageError) { assumeNoException("libX11 is not available", e) }
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,18 @@ compose {
|
||||
extraKeysRawXml = """
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>SimpleX needs microphone access to record voice messages</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>$bundleID</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>simplexchat</string>
|
||||
<string>simplex</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
"""
|
||||
}
|
||||
val identity = rootProject.extra["desktop.mac.signing.identity"] as String?
|
||||
|
||||
@@ -9,19 +9,27 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import chat.simplex.common.acquireSingleInstance
|
||||
import chat.simplex.common.installOpenUriHandler
|
||||
import chat.simplex.common.linkFromArgs
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.size
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.platform.DesktopPlatform
|
||||
import chat.simplex.common.showApp
|
||||
import chat.simplex.common.singleInstanceLock
|
||||
import chat.simplex.common.startShowFileWatcher
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
|
||||
fun main() {
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
if (!acquireSingleInstance()) return
|
||||
val link = linkFromArgs(args)
|
||||
if (!acquireSingleInstance(link)) return
|
||||
link?.let { chatModel.appOpenUrl.value = null to it }
|
||||
// started after the launch link is stored, so a link it forwards later is not overwritten
|
||||
if (singleInstanceLock) startShowFileWatcher()
|
||||
// Clean shared temp dirs only in the owning instance (not in a Files.desktop val
|
||||
// initializer, which a transient second instance would also run). Early: before settings writes.
|
||||
preferencesTmpDir.deleteRecursively()
|
||||
@@ -31,13 +39,14 @@ fun main() {
|
||||
runMigrations()
|
||||
setupUpdateChecker()
|
||||
initApp()
|
||||
registerLinkSchemes()
|
||||
tmpDir.deleteRecursively()
|
||||
tmpDir.mkdir()
|
||||
// Only the owning instance cleans tmpDir on exit (see preferencesTmpDir above).
|
||||
tmpDir.deleteOnExit()
|
||||
// showApp is inside the try: its first statements (SystemTray probe, Compose setup) are the
|
||||
// process's first AWT init, which is itself a known startup failure cause (#4146). Crashes
|
||||
// after the window appears are handled by the WindowExceptionHandler in showApp instead.
|
||||
// installOpenUriHandler and showApp stay inside the try: whichever touches AWT first makes the process's
|
||||
// first AWT init, a known startup failure cause (#4146); later crashes go to showApp's WindowExceptionHandler.
|
||||
if (desktopPlatform.isMac()) installOpenUriHandler()
|
||||
return showApp()
|
||||
} catch (e: Throwable) {
|
||||
showStartupError(e) // the jpackage launcher otherwise hides the error behind "Failed to launch JVM" (#4146)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Type=Application
|
||||
Name=SimpleX Chat
|
||||
Comment=Private and secure open-source messenger - no user IDs (not even random numbers)
|
||||
Exec=/opt/simplex/bin/simplex
|
||||
Exec=/opt/simplex/bin/simplex %u
|
||||
Icon=/opt/simplex/lib/simplex
|
||||
Categories=Network;Chat;
|
||||
MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;
|
||||
|
||||
@@ -101,6 +101,7 @@ Key files that exist only on one platform, grouped by concern.
|
||||
| `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt` | Deep link handling from launch arguments and macOS |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection |
|
||||
| `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation |
|
||||
|
||||
@@ -13,6 +13,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des
|
||||
5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified)
|
||||
6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented)
|
||||
7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented)
|
||||
8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration)
|
||||
|
||||
---
|
||||
|
||||
@@ -289,3 +290,35 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self
|
||||
|
||||
- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop.
|
||||
- The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed.
|
||||
|
||||
---
|
||||
|
||||
## GAP-08: Desktop: App Link Registration
|
||||
|
||||
**Severity:** Low
|
||||
**Category:** Platform
|
||||
**Platform:** Desktop only
|
||||
|
||||
### Description
|
||||
|
||||
Desktop registers the `simplexchat:` scheme so the badge page can return to the app, and the `simplex:` scheme so connection links open in it. The registration has known limits:
|
||||
|
||||
- Uninstalling or deleting the app leaves the per-user registration behind: the HKCU keys on Windows (the scheme keys, the ProgIds, `Capabilities` and the `RegisteredApplications` entry) and the hidden AppImage desktop entry on Linux. A later link then points at a missing program. On Windows, if the app was installed into a folder other users can write to (for example a custom folder at the root of `C:\`), another user could put a program at that path and have it run on the next link; the default Program Files install is not affected. This risk is accepted.
|
||||
- A Flatpak counts as registered without a check, as it cannot query the host's default handler. It works only once the Flathub release pins a commit with the desktop file's MimeType line and its wrapper passes `"$@"` to the app.
|
||||
- On macOS a second copy of the app does not forward a link to the running one; macOS delivers the link to the running bundle itself.
|
||||
- With two installations (a deb and an AppImage, or two installed copies of the Windows app), the scheme stays with whichever registered first while it is installed; the other gets `app=desktop`. On Windows, when several apps registered through `Capabilities` handle a scheme and none was picked, Windows chooses or asks; the app cannot set `UserChoice` itself.
|
||||
- The link arrives as a command-line argument, which other local users can read through `/proc` on Linux. This risk is accepted.
|
||||
|
||||
### Affected Locations
|
||||
|
||||
- `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt` -- registration and its result for the badge page
|
||||
- `common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt` -- forwarding to a running instance
|
||||
|
||||
### Impact
|
||||
|
||||
A stale registration or an unregistered Flatpak sends the user back to a link that opens nothing; the badge page's *Show code* and the Redeem code screen remain.
|
||||
|
||||
### Recommendation
|
||||
|
||||
1. Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone.
|
||||
2. Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment.
|
||||
|
||||
@@ -107,7 +107,7 @@ Common Module (commonMain)
|
||||
| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source |
|
||||
| Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) |
|
||||
| Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories |
|
||||
| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations |
|
||||
| Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations |
|
||||
| Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle |
|
||||
| Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles |
|
||||
|
||||
@@ -119,7 +119,7 @@ Common Module (commonMain)
|
||||
|---|---|---|---|
|
||||
| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 |
|
||||
| Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 |
|
||||
| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 |
|
||||
| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) | `fun main(args: Array<String>)` | 26 |
|
||||
| Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 |
|
||||
| Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 |
|
||||
| Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 |
|
||||
@@ -130,8 +130,8 @@ Common Module (commonMain)
|
||||
| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 |
|
||||
| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | `abstract class NtfManager` | 20 |
|
||||
| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 |
|
||||
| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 |
|
||||
| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 |
|
||||
| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L36) | `fun initHaskell(packageName: String)` | 36 |
|
||||
| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L48) | `fun runMigrations()` | 48 |
|
||||
| Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 |
|
||||
| Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 |
|
||||
| Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 |
|
||||
|
||||
@@ -222,21 +222,29 @@ Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`):
|
||||
|
||||
### Desktop
|
||||
|
||||
Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21)
|
||||
Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26)
|
||||
|
||||
```
|
||||
main()
|
||||
main(args)
|
||||
+-- linkFromArgs(args) // A simplexchat: or simplex: link passed by the OS
|
||||
+-- acquireSingleInstance(link) // Or forward the link to the running instance and exit
|
||||
+-- appOpenUrl = link // Stored before the watcher can forward a newer link
|
||||
+-- startShowFileWatcher() // Only while holding the single-instance lock
|
||||
+-- initHaskell() // Load native lib from resources dir, call initHS()
|
||||
| +-- System.load(libapp-lib.so/dll/dylib)
|
||||
| +-- initHS()
|
||||
+-- runMigrations()
|
||||
+-- setupUpdateChecker()
|
||||
+-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart
|
||||
+-- registerLinkSchemes() // Background thread, per-OS registration and detection
|
||||
+-- installOpenUriHandler() // macOS only: links arrive as Apple Events
|
||||
+-- showApp() // Compose window with AppScreen()
|
||||
```
|
||||
|
||||
[`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window.
|
||||
|
||||
[`showWindow()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L258) un-minimizes and raises the window when a second launch or a link reaches the running app. On Linux it also sends `_NET_ACTIVE_WINDOW` through [`requestX11Activation()`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt#L17), because wlroots compositors such as Sway ignore the raise that AWT's `toFront()` sends under XWayland. On Windows the second process, which the browser left holding the foreground right, calls `AllowSetForegroundWindow` before it signals so that the running instance may raise its window. During a crash restart `showWindow()` skips the disposed window, so it does not come back; the new window opens visible.
|
||||
|
||||
[`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`.
|
||||
|
||||
---
|
||||
@@ -341,8 +349,8 @@ Examples from platform files:
|
||||
|
||||
[`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point:
|
||||
|
||||
- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187)
|
||||
- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50)
|
||||
- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L193)
|
||||
- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L59)
|
||||
|
||||
The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50):
|
||||
```kotlin
|
||||
@@ -417,6 +425,8 @@ var platform: PlatformInterface = object : PlatformInterface {}
|
||||
| SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service |
|
||||
| Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` |
|
||||
| DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` |
|
||||
| SingleInstance.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt) | `acquireSingleInstance()`, `startShowFileWatcher()`, signal file hand-over |
|
||||
| WindowActivation.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt) | `requestX11Activation()`, `sendActivation()`, `activationEvent()` |
|
||||
|
||||
### Theme
|
||||
|
||||
|
||||
@@ -109,9 +109,11 @@ When onboarding is complete:
|
||||
|
||||
### URL Deep Link
|
||||
|
||||
On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. Desktop registers no URL scheme.
|
||||
On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active.
|
||||
|
||||
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L796) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L800) dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L268), which shows `BadgesRedeemLinkView` as a `ModalManager.end` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added. Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L265)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string.
|
||||
On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L53): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.<scheme>` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing.
|
||||
|
||||
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L797) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L268), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added. Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L265)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string.
|
||||
|
||||
### Overlay Layers (bottom of MainScreen)
|
||||
|
||||
@@ -382,3 +384,5 @@ The stage value is stored in `appPrefs.onboardingStage` and persisted across app
|
||||
| `views/SplashView.kt` | Splash / loading screen |
|
||||
| `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) |
|
||||
| `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) |
|
||||
| `AppLinks.kt` (desktopMain) | Desktop app links from launch arguments and macOS Apple Events |
|
||||
| `platform/AppLinkScheme.desktop.kt` (desktopMain) | Runtime `simplexchat:` registration and its result for the badge page |
|
||||
|
||||
@@ -415,6 +415,10 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/`
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery |
|
||||
| `StoreWindowState.kt` | — | Low | Window position/size persistence |
|
||||
| `AppLinks.kt` | — | Medium | `simplexchat:` and `simplex:` links from launch arguments and macOS Apple Events |
|
||||
| `SingleInstance.kt` | — | Medium | Single-instance lock; forwards a second launch and its link to the running app |
|
||||
| `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland |
|
||||
| `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) |
|
||||
| `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display |
|
||||
| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations |
|
||||
| `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) |
|
||||
|
||||
@@ -22,7 +22,7 @@ Everything in the app sections below is both platforms unless it says otherwise.
|
||||
4. **`purchaseBadge` with store receipts**, which depends on none of the above and can run alongside it.
|
||||
5. **The app's store lane**, last, because nothing in it completes without 4.
|
||||
|
||||
(*) **Desktop deep links** sit outside this order entirely — any time or never, blocking nothing. Own section below.
|
||||
(*) **Desktop deep links** sat outside this order entirely.
|
||||
|
||||
## The page
|
||||
|
||||
@@ -66,15 +66,17 @@ Two invariants that are easy to miss. A store transaction must not be finished u
|
||||
|
||||
## Desktop (D1, D2)
|
||||
|
||||
No deep link. *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing.
|
||||
Where the scheme is not registered, there is no deep link: *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing.
|
||||
|
||||
## Desktop deep links (*)
|
||||
|
||||
Optional, off the critical path, and blocking nothing — D1 works without it. Worth investigating on its own; if it lands, desktop joins lane A and D1 becomes the fallback rather than the design.
|
||||
This was optional and off the critical path, and it blocked nothing, since D1 works without it.
|
||||
|
||||
Nothing registers a scheme on desktop today. `appOpenUrl` is wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declares Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` that the installer would have to write on Windows, and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way.
|
||||
The plan is `plans/2026-09-28-desktop-deep-links.md`. The feature is implemented, and verification on real machines is pending. Desktop joins lane A wherever the scheme is registered, and D1 is the fallback rather than the design.
|
||||
|
||||
Getting the URL to an app that is already running is the part that half exists. `SingleInstance.kt` takes a file lock and uses a watched file to bring the running instance forward on a second launch; carrying a URL means giving that channel a payload rather than inventing IPC for it.
|
||||
Before that work, nothing registered a scheme on desktop. `appOpenUrl` was wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declared Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` on Windows (the app now writes them at runtime), and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way.
|
||||
|
||||
Getting the URL to an app that is already running was the part that half existed. `SingleInstance.kt` took a file lock and used a watched file to bring the running instance forward on a second launch; the implementation gives that channel a payload rather than inventing IPC for it.
|
||||
|
||||
## The service
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Desktop deep links: report
|
||||
|
||||
Packaged desktop builds on macOS, Windows and Linux receive `simplexchat:` links from the badge page, and `simplex:` connection links, with the app closed, running or hidden in the tray. Unit tests and builds pass; no packaged build has been run on real machines yet, so the manual checklist below is open.
|
||||
|
||||
The plan is `plans/2026-09-28-desktop-deep-links.md`.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
page: Return to SimpleX → simplexchat:/badge/code/<code>
|
||||
macOS Apple Event → installOpenUriHandler → openDesktopLink
|
||||
Windows, Linux new process main(args) → linkFromArgs
|
||||
lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts
|
||||
lock taken → simplex.show with the link → running app → openDesktopLink
|
||||
openDesktopLink: chatModel.appOpenUrl = remoteHostId() to link, then showWindow()
|
||||
App.kt LaunchedEffect → connectIfOpenedViaUri → openAppLink → BadgesRedeemLinkView (asks before redeeming)
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
| Build | Registered by | What is written | Registered when |
|
||||
|---|---|---|---|
|
||||
| macOS `.dmg` | Info.plist, at build | `CFBundleURLTypes` with schemes `simplexchat` and `simplex` in `desktop/build.gradle.kts` | copied to /Applications; LaunchServices picks it up |
|
||||
| Windows `.msi`, `.exe` | the app, each start | `HKCU\Software\Classes\simplexchat` and `\simplex`: `URL Protocol`, `DefaultIcon`, `shell\open\command = "<SimpleX.exe>" "%1"`; ProgIds `SimpleX.simplexchat` and `SimpleX.simplex` with the same command; `HKCU\Software\chat.simplex.app\Capabilities` with `URLAssociations`, listed in `HKCU\Software\RegisteredApplications` | first launch per user; rewritten when a value is missing or the command's program is gone, not while another installed program, per-user or machine-wide, holds it; registered when Windows (`AssocQueryStringW`) names this exe as the program for `simplexchat:` |
|
||||
| Linux `.deb` | `make-deb-linux.sh`, at build | jpackage's `simplex-simplex.desktop` gets `Exec=... %u` and `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` | install; postinst `xdg-desktop-menu install` refreshes the MIME cache |
|
||||
| AppImage | the app, each start; also the bundled entry | hidden `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop` with `Exec=<APPIMAGE> %u`, the path quoted only when the desktop-entry spec requires it, then `xdg-mime default` for each scheme; the bundled `chat.simplex.app.desktop` declares the MimeType for AppImage integration tools | first launch; rewritten if the AppImage moved; the default is set only when none is set or the current handler's entry is no longer installed |
|
||||
| Flatpak | `scripts/flatpak/chat.simplex.simplex.desktop` | `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` (Exec already has `%U`) | install of a Flathub release whose pinned commit includes this file |
|
||||
|
||||
The registration code is in `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt`. It runs once on a background thread after `initApp()`. The installation kind comes from `desktopInstallation(platform, appPath, env)`. An unpackaged run (no `jpackage.app-path`) registers nothing, and `APPIMAGE` counts only when the launcher runs from `APPDIR`, resolved through symlinks, because the AppImage runtime exports it to every child process. The AppImage entry is rewritten, followed by `update-desktop-database`, only when its content changed. An AppImage is not registered when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it, since the entry would run that file for every link; POSIX ACLs are not inspected. A path with a line break or `%` is not registered either, since GLib looks the program up before it expands `%%`. The path in `Exec` is quoted only when the desktop-entry spec requires it, because `xdg-open` 1.1.3 (Debian 12, Ubuntu 24.04) cannot run a quoted program in its generic mode, which Sway and other desktops it does not recognise use.
|
||||
|
||||
## Receiving the link
|
||||
|
||||
- **Closed, Windows and Linux:** the link is the only argument of `main`. `linkFromArgs` accepts it only when it is the single argument, starts with `simplexchat:` or `simplex:` and is at most 8192 bytes in UTF-8, enough for a one-time link with its post-quantum key (about 2 KB) and for a second such key.
|
||||
- **Running, Windows and Linux:** the second process writes the link to an owner-only temp file in `dataDir` and renames it to `simplex.show`; if that fails, it writes an empty signal, which still raises the running app. The running app renames it to a private name, accepts at most 8192 bytes, deletes it, opens the link and shows its window. A signal present when the watcher starts is handled too, unless it predates the lock attempt by more than a 2 s tolerance for file time granularity; such a signal is left by an earlier session and is deleted instead, as are old temp files and a taken file left by a crash. On Windows the second process first calls `AllowSetForegroundWindow` so the window can come to the front, and logs a refusal.
|
||||
- **Hung running app:** the existing "not responding" alert stays. "Start anyway" withdraws the signal and the new process opens the link itself; if the running app recovered and took the signal while the alert was up, the new process exits instead, so the link opens once. If renaming the signal fails, for example while another program holds it open, the running app retries once after 100 ms.
|
||||
- **macOS:** the link arrives as an Apple Event, closed or running. `installOpenUriHandler` sets the handler on macOS only; the JDK holds an event that arrives during startup until then.
|
||||
- **Bringing the window forward on Linux:** AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops and focus stealing prevention refuses. `showWindow` makes the window visible at once and also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA, after syncing AWT's connection so the map request comes first. On Sway the result follows `focus_on_window_activation`: the default `urgent` marks the window urgent, `smart` or `focus` focuses it. This was verified on headless Sway 1.9 with XWayland.
|
||||
|
||||
## Badge page ending
|
||||
|
||||
`appLinkSchemeRegistered()` decides the page flag when *Buy in browser* is tapped:
|
||||
|
||||
| Result | Page URL | App side |
|
||||
|---|---|---|
|
||||
| registered | `#/tier?app=true` | page ends with *Return to SimpleX* and *Show code* |
|
||||
| not registered | `#/tier?app=desktop` | Redeem code screen opens beside the browser (fallback) |
|
||||
|
||||
Only `simplexchat:` counts here; `simplex:` is registered alongside it without being checked. By build, macOS is registered when packaged; Windows when the shell (`AssocQueryStringW`) names this exe as the program for `simplexchat:`; Flatpak always; AppImage and deb when `xdg-mime` reports their own entry as default. Android always reports registered. The button reads the flag once, so the URL and the fallback screen agree.
|
||||
|
||||
## Security
|
||||
|
||||
- The link is a bearer token. No log line on this path includes it.
|
||||
- Any web page or program can send a link. The redeem screen from #7592 asks before redeeming and names the profile.
|
||||
- The code arrives as a command-line argument. On Linux any local user can read it through `/proc/<pid>/cmdline` unless `/proc` is mounted with `hidepid`; on a cold start the argument belongs to the app itself and stays readable until it exits, otherwise to the second process for about a second. On Windows only the same user and administrators can read it. This is an accepted risk; removing it needs the page to encrypt the code to a key the app passes in the URL fragment.
|
||||
- The signal file holding the link is owner-only on POSIX and deleted as soon as it is read. It stays on disk only if the running app is hung. A later start that takes the lock deletes it without opening the link once it is more than 2 s older than the lock attempt; while the hung app still holds the lock, a later launch replaces the file and shows the alert again.
|
||||
- Another program can claim the same scheme (on Windows, an app registered through `Capabilities` or picked in default-app settings wins; on Linux, the last `xdg-mime default` wins). That risk is accepted, and *Show code* on the page remains.
|
||||
|
||||
## Automated verification (done)
|
||||
|
||||
- `./gradlew :common:desktopTest`: all tests pass; this work adds `AppLinksTest`, `AppLinkSchemeTest`, `BadgePageUrlTest`, `WindowActivationTest` and nineteen `SingleInstanceTest` cases.
|
||||
- `AppLinksTest`: accepts `simplexchat:` and `simplex:` in any case, including a one-time link with its post-quantum key; rejects `https:`, another scheme sharing the prefix, a path, empty, a link that is not the only argument, and a link over 8192 bytes, including a multi-byte one shorter than that in characters.
|
||||
- `SingleInstanceTest`: link and empty signals round trip and are taken once; a later signal replaces an untaken one, and a taken file left by a crash is replaced; the file is owner-only (on POSIX); a link of exactly 8192 bytes and a one-time link with its post-quantum key are accepted; non-link content, an oversized link and a multi-byte link over the byte bound are dropped and still removed; the temp file is created beside the signal (skipped where the watch service polls, as on macOS); the startup sweep removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, and keeps a signal within the 2 s tolerance but drops an older one; the watcher takes a signal written before its watch, and successive signals arriving while it watches (skipped where the watch service polls, as on macOS), through a real `WatchService`, each once.
|
||||
- `AppLinkSchemeTest`: Windows command quoting, the registry values, the deb counting as registered only when its own entry is the default, the ProgIds and the application listing, and registration with a stand-in shell (writing every value when the commands point at a missing program, leaving a full registration alone without notifying Windows, repairing a missing value or a missing application listing and notifying, keeping another installed program's or another copy's handler per-user or machine-wide, repairing this app's ProgId while another program holds the scheme, updating a command for this exe with other arguments, leaving a scheme alone when its handler cannot be read or its ProgId cannot be written, and reporting registered only when the shell names this exe, in any letter case); desktop-entry quoting that leaves a plain path bare, quotes every reserved character, keeps spaces inside the quotes and escapes `"`, `` ` ``, `$` and `\`; the full AppImage entry and its refusal of line breaks and `%`; `desktopInstallation` precedence, including Flatpak over a matching AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration writing the entry into a missing directory and setting both defaults, leaving unchanged defaults alone, setting the defaults again for an unchanged entry, setting only the default that points elsewhere, rewriting the entry of a moved AppImage, failing when another handler keeps the `simplexchat:` default, succeeding when it keeps only the `simplex:` one, and refusing a path the entry cannot run; `runProcess` output, discarded error output, an end of input and failure.
|
||||
The ownership, AppImage registration and `runProcess` cases need POSIX permissions and `sh`, and the symlinked-mount case needs symlink rights, so they are skipped on Windows.
|
||||
- `BadgePageUrlTest`: `app=true` when the link returns, `app=desktop` otherwise.
|
||||
- `WindowActivationTest`: the `_NET_ACTIVE_WINDOW` client message targets the window with the pager source and `CurrentTime`, checked in native memory; it is sent to the root window with the window manager's event mask, and the display is closed even when sending fails (skipped where libX11 cannot load, as on Windows and macOS).
|
||||
- `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`: succeed, no new warnings.
|
||||
- The `make-deb-linux.sh` sed applied to the v7.0.3 deb's desktop file gives the intended lines, and `update-desktop-database` lists it for `x-scheme-handler/simplexchat`.
|
||||
- The AppImage entry passes `desktop-file-validate` and reaches the MIME cache.
|
||||
|
||||
## Manual verification (to do)
|
||||
|
||||
Test with `simplexchat:/badge/code/<code>` from a real purchase, or with the page mock (`apps/simplex-badge-service/web/mock/server.py`, open `#/tier?app=true` in a private window, settle with `/control/settle/<invoiceId>`).
|
||||
|
||||
### macOS (signed `.dmg`, installed to /Applications)
|
||||
- [ ] `lsregister -dump | grep simplexchat` lists the installed app
|
||||
- [ ] app closed: `open 'simplexchat:/badge/code/<code>'` starts it and shows the redeem screen
|
||||
- [ ] app running, window behind others: link raises it and shows the redeem screen
|
||||
- [ ] app hidden in the tray: link shows the window
|
||||
- [ ] dmg still mounted beside the installed copy: the installed copy opens
|
||||
- [ ] Safari, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app
|
||||
|
||||
### Windows (`.msi` install)
|
||||
- [ ] after first launch, `HKCU\Software\Classes\simplexchat\shell\open\command` is `"C:\Program Files\SimpleX\SimpleX.exe" "%1"`
|
||||
- [ ] app closed: `start simplexchat:/badge/code/<code>` starts it and shows the redeem screen
|
||||
- [ ] app running: link reaches the running app, window comes to the front
|
||||
- [ ] app hidden in the tray: link shows the window
|
||||
- [ ] the argument arrives as one piece after the launcher restarts itself
|
||||
- [ ] Edge, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app
|
||||
- [ ] after an MSI upgrade, the key still points at the installed exe
|
||||
- [ ] SimpleX is offered under *Settings → Default apps → SIMPLEXCHAT*; picking another app there makes the page end with `app=desktop` after a restart
|
||||
- [ ] fresh install, no choice made: the page ends with `app=true`, and `start simplexchat:/badge/code/<code>` opens SimpleX
|
||||
- [ ] with another app registered through `Capabilities` and no choice made: the page's ending matches what `start simplexchat:` actually opens (`app=true` only if SimpleX opens)
|
||||
|
||||
### Linux
|
||||
- [ ] deb on GNOME and KDE: `xdg-mime query default x-scheme-handler/simplexchat` gives `simplex-simplex.desktop`
|
||||
- [ ] deb, app closed and running: `xdg-open 'simplexchat:/badge/code/<code>'` shows the redeem screen
|
||||
- [ ] AppImage: after first launch the hidden entry exists and is the default; moving the AppImage and relaunching updates it
|
||||
- [ ] Flatpak: link opens the Flatpak app (needs the Flathub update)
|
||||
- [ ] Firefox and Chrome from the distribution, snap Firefox on Ubuntu, Flatpak Firefox: *Return to SimpleX* opens the app
|
||||
- [ ] GNOME on Wayland: window comes forward
|
||||
- [ ] Sway: default config marks SimpleX urgent; with `focus_on_window_activation focus` the link focuses it
|
||||
- [ ] deb and AppImage: `xdg-mime query default x-scheme-handler/simplex` names the app's entry
|
||||
|
||||
### All platforms
|
||||
- [ ] app locked with a passcode: the redeem screen appears after unlock
|
||||
- [ ] no active profile: the link waits until a profile is active
|
||||
- [ ] desktop connected to a mobile: the badge goes to the active remote profile
|
||||
- [ ] a second link while one redeems is ignored
|
||||
- [ ] no log file or console output contains the code
|
||||
- [ ] a `simplex:` contact or one-time link from a browser, app closed and running: the connect dialog opens and asks before connecting
|
||||
- [ ] unpackaged `./gradlew run`: page gets `app=desktop`, Redeem code screen opens beside the browser
|
||||
- [ ] `./gradlew runDistributable` and the installed app keep each other's registration while both exes exist; delete the build output (or the HKCU keys) to give links back to the installed app
|
||||
- [ ] two installed copies: the scheme stays with the first; after uninstalling it, the next start of the other copy takes the scheme
|
||||
|
||||
## Known gaps
|
||||
|
||||
The registration gaps below are also recorded as GAP-08 in `apps/multiplatform/product/gaps.md`.
|
||||
|
||||
- A Flatpak always counts as registered, since the sandbox cannot query the host's default handler. If another installation holds the default, the page sends `app=true` and the link opens that installation; *Show code* still works.
|
||||
- On Sway the window only becomes urgent unless the user sets `focus_on_window_activation smart` or `focus`. No client-side request can do more: Sway accepts activation tokens only for native Wayland windows.
|
||||
- The Flathub release bump must pin a simplex-chat commit that includes the desktop file's MimeType line, and the Flathub wrapper must pass `"$@"` (done on its `update` branch).
|
||||
- The HKCU key and the AppImage entry stay after uninstalling or deleting the app. On Windows, after an install into a folder other users can write to (such as a custom folder at the root of `C:\`), another user could place a program at the registered path; this accepted risk does not apply to the default Program Files install.
|
||||
- On macOS, if LaunchServices starts a second copy of the app while one runs, that copy forwards no link: the URL arrives as an Apple Event the second copy never handles.
|
||||
- jpackage's deb desktop file already has `Categories=Unknown`, which `desktop-file-validate` rejects, and a generic `Name=simplex`. The scheme still registers, and this work leaves both as they are.
|
||||
|
||||
## Commits
|
||||
|
||||
| Commit | Change |
|
||||
|---|---|
|
||||
| `tests: close the walk stream in withTempDir` | `Files.walk` closed in the existing `SingleInstanceTest` helper |
|
||||
| `desktop: receive app links from args and macOS` | `AppLinks.kt`, `main(args)`, macOS handler |
|
||||
| `desktop: forward an app link to the running app` | signal file payload in `SingleInstance.kt` |
|
||||
| `desktop: initialize the desktop platform lazily` | `desktopPlatform` class initialization cycle |
|
||||
| `tests: share the temp directory helper` | `withTempDir` moved to `TempDir.kt` for the scheme tests |
|
||||
| `desktop: register simplexchat scheme in packages` | Info.plist, deb, AppImage and Flatpak desktop entries |
|
||||
| `desktop: register simplexchat scheme at runtime` | `AppLinkScheme.desktop.kt` (Windows, AppImage, detection) |
|
||||
| `ui: end the badge page with a link on desktop` | `appLinkSchemeRegistered()`, `badgePageUrl(linkReturns)`, fallback |
|
||||
| `desktop: raise the window over other apps` | `isVisible` in `showWindow` and its skip of a disposed window during a crash restart, `_NET_ACTIVE_WINDOW` in `WindowActivation.kt`, `AllowSetForegroundWindow` on Windows |
|
||||
| `docs: describe desktop deep links in the spec` | `navigation.md`, `architecture.md`, spec `README.md`, `impact.md`, `CODE.md` Document Map, `concepts.md`, `gaps.md` (GAP-08) |
|
||||
| `docs: plan desktop deep links` | badge plan, plan |
|
||||
| `docs: report on desktop deep links` | this report |
|
||||
| `desktop: open simplex connection links` | `simplex:` accepted and registered beside `simplexchat:`, one 8192-byte bound, desktop link names without `App` |
|
||||
| `docs: describe simplex links on desktop` | spec, `gaps.md`, plan and this report |
|
||||
| `desktop: keep a link from opening twice` | `startDespiteRunningInstance()`: "Start anyway" withdraws the signal, and exits if the running app already took it |
|
||||
| `desktop: retry taking a signal held open` | `takeSignal` retries a failed rename once |
|
||||
| `desktop: keep the user's AppImage link handler` | the AppImage takes a scheme only when no handler is set or the current handler's entry is no longer installed |
|
||||
| `desktop: register Windows links through Capabilities` | scheme keys and `SimpleX.<scheme>` ProgIds, `Capabilities` with `URLAssociations` and `RegisteredApplications`; every value compared and repaired; another installed program's or copy's handler kept, per-user or machine-wide; registered when `AssocQueryStringW` names this exe; `SHChangeNotify` only after a change |
|
||||
| `desktop: add %u to the deb entry only once` | the deb's `Exec` sed skips a line that already has a field code |
|
||||
| `docs: describe the review fixes` | spec, `gaps.md`, plan and this report |
|
||||
@@ -0,0 +1,145 @@
|
||||
# Desktop deep links: `simplexchat:` on Linux, Windows, macOS
|
||||
|
||||
## Context
|
||||
|
||||
The badge page (`badges.simplex.chat`) ends a purchase opened from the app with *Return to SimpleX*, which navigates to `simplexchat:/badge/code/<code>`. Android and iOS handle it since #7592 (`isAppLink` → `openAppLink` → `openBadgeLink` → `BadgesRedeemLinkView`, which confirms before redeeming). Desktop registers no scheme, so it opens the page with `app=desktop` and relies on the user pasting the code (D1). Goal: every packaged desktop build receives the link, cold or running, and sends `app=true` only when a link is expected to reach this installation. D1 stays as the fallback.
|
||||
|
||||
The decisions were to build on #7592, forward to a running instance through the existing signal file with a payload, register `simplexchat:` only (`simplex:` connection links were added afterwards, see the last section), and self-register at runtime on Windows (HKCU) and AppImage.
|
||||
|
||||
## Research summary
|
||||
|
||||
- **macOS**: `CFBundleURLTypes` in Info.plist; LaunchServices registers on copy to /Applications. URL arrives only as an Apple Event through `Desktop.setOpenURIHandler`; JDK 17 queues it until the handler is set (`_OpenURIDispatcher`), so cold start is safe for AWT/Compose. The running app receives it in-process, so no second process starts.
|
||||
- **Windows**: `HKCU\Software\Classes\simplexchat` with `URL Protocol`, `shell\open\command = "<exe>" "%1"`. Compose 1.8.2 exposes no `--resource-dir`, so installer registration needs a WiX fork; runtime HKCU is what Signal, Element, Telegram do. URL arrives as argv of a new process. `jpackage.app-path` is set by the jpackage launcher (JDK 16+).
|
||||
- **Linux**: `.desktop` with `MimeType=x-scheme-handler/simplexchat;` and `Exec=... %u`, visible once `mimeinfo.cache` is rebuilt. The v7.0.3 deb ships `/opt/simplex/lib/simplex-simplex.desktop` with `Exec=/opt/simplex/bin/simplex` and empty `MimeType=`; its postinst runs `xdg-desktop-menu install`, which runs `update-desktop-database`. AppImage has no install step, so it self-registers in `~/.local/share/applications`. Flatpak exports the `.desktop` MimeType (file is in `scripts/flatpak/`).
|
||||
- **Browsers**: all prompt before launching; Chrome needs a user gesture (the page's button provides it). No reliable way for the page to know the app opened, so *Show code* stays.
|
||||
- **Security**: link is a bearer token in argv, which on Linux other local users can read through `/proc` (for the whole session after a cold start); this risk is accepted. The lane already confirms before redeeming. Nothing may log the URI.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
browser ─ simplexchat:/badge/code/X
|
||||
macOS: Apple Event → setOpenURIHandler → openDesktopLink
|
||||
Windows/Linux: new process main(args) → linkFromArgs
|
||||
lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts
|
||||
lock taken → simplex.show payload → primary watcher → openDesktopLink
|
||||
openDesktopLink: chatModel.appOpenUrl = remoteHostId() to uri; showWindow()
|
||||
App.kt LaunchedEffect (existing) → connectIfOpenedViaUri → openAppLink (lane)
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
1. **`common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt`** (new, beside `SingleInstance.kt`)
|
||||
- `isAcceptedLink(uri)`: `isAppLink` (`ChatListView.kt`) within `MAX_LINK_BYTES` UTF-8 bytes.
|
||||
- `linkFromArgs(args)`: the only argument, when it is an accepted link; anything else is ignored.
|
||||
- `openDesktopLink(uri)`: on the EDT, `chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri`, then `showWindow()` (`DesktopApp.kt:258`), so a failure to show the window cannot lose the link. `remoteHostId()` matches the redeem screen (`BadgesRedeemCodeView.kt:98`).
|
||||
- `installOpenUriHandler()`: called on macOS only (so Windows and Linux do not start AWT early); sets a handler that forwards accepted links to `openDesktopLink`.
|
||||
|
||||
2. **`desktop/.../Main.kt`**: `main(args)`. Compute the link and pass it to `acquireSingleInstance(link)`; when this process runs, put it into `chatModel.appOpenUrl` (`null` host), then start the watcher if this process holds the lock, so a link the watcher forwards later is not overwritten. A short-lived second process never touches `chatModel`. After `initApp()`: start registration (step 4) and `installOpenUriHandler()`. Keep everything inside the existing try.
|
||||
|
||||
3. **`SingleInstance.kt`**: the signal carries an optional payload.
|
||||
- Second process: `Files.createTempFile(dataDir, "simplex.show", ".tmp")` (owner-only on POSIX), write the link or nothing, then `Files.move(tmp, showPath, ATOMIC_MOVE)`, which replaces an untaken signal on Linux and Windows. If that fails, fall back to an empty signal, which still brings the running instance forward. The rename arrives as `ENTRY_CREATE` on inotify and Windows. Existing 1 s wait and hung-primary alert stay; on "start anyway" the payload file is deleted and `main` handles the link itself.
|
||||
- Primary watcher: rename `simplex.show` to a private temp name, so a newer signal renamed over it is not deleted unread, then accept at most `MAX_LINK_BYTES` bytes, delete it, and post `openDesktopLink(link)` or `showWindow()`.
|
||||
- Primary start deletes a taken file left by a crash, and any `simplex.show*` file older than its lock attempt minus a 2 s tolerance for file time granularity: such a signal or temp file was left by an earlier session, while a fresh temp file is a signal being written. `main` then starts the watcher, which registers the watch and handles a signal already present, which raised no event.
|
||||
- Windows: call `AllowSetForegroundWindow(ASFW_ANY)` before signalling so the primary can come forward (best effort). jna-platform 5.14 does not bind it, so it is looked up through `NativeLibrary`.
|
||||
- Take the directory as a parameter internally so tests can use a temp dir.
|
||||
- No log line includes the link.
|
||||
|
||||
4. **`common/src/desktopMain/.../platform/AppLinkScheme.desktop.kt`** (new): `registerLinkSchemes()` runs once on a background thread and sets a `@Volatile` result read by the UI. `unixDataHome` is exposed from `Platform.desktop.kt` and `appLinkScheme` from `ChatListView.kt` for reuse.
|
||||
- macOS: registered when `jpackage.app-path` is set (packaged bundle).
|
||||
- Windows: when `jpackage.app-path` is set, read the HKCU command via `Advapi32Util`. If it differs from `"<path>" "%1"`, write the default value `URL:SimpleX Chat`, `URL Protocol`, `DefaultIcon`, and `shell\open\command`. Registered if the key now matches.
|
||||
- Linux:
|
||||
- `container=flatpak` set (the check `AppUpdater` uses) → registered.
|
||||
- AppImage, when `APPIMAGE` and `APPDIR` are set and the launcher runs from `APPDIR`, resolved through symlinks (the runtime exports `APPIMAGE` to every child process) → write `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop`: `NoDisplay=true`, `Exec=<APPIMAGE> %u` with the path quoted only when the desktop-entry spec requires it (`xdg-open` 1.1.3 cannot run a quoted program), and the MimeType line; a path with a line break or `%` is refused (GLib looks the program up before it expands `%%`), and so is an AppImage whose file or any directory above it another user could replace (owned by anyone but the user or root, writable by others, or group-writable by a group other than the user's own; POSIX ACLs are not inspected). Rewrite it and run `update-desktop-database` only when the content differs, then run `xdg-mime default` unless the entry is already the default.
|
||||
- Otherwise (deb): registered when `xdg-mime query default x-scheme-handler/simplexchat` names the deb's own `simplex-simplex.desktop`. Processes are run without a shell and with a timeout.
|
||||
- The decision is the function `desktopInstallation(platform, appPath, env)`, so it is unit tested.
|
||||
- `desktopPlatform` becomes lazy in its own commit: the new tests reference a `DesktopPlatform` constant first, and the eager value was then null through a class initialization cycle.
|
||||
- Unpackaged runs register nothing and report false.
|
||||
|
||||
5. **Common UI**
|
||||
- `expect fun appLinkSchemeRegistered(): Boolean` in `platform/AppCommon.kt`, with Android actual `true` and desktop actual returning step 4's result.
|
||||
- `BadgeStore.kt`: `badgePageUrl(linkReturns)` returns `app=true` or `app=desktop`; the button reads the flag once and passes it, so the URL and the fallback screen agree.
|
||||
- `BadgesSupportSimplexView.kt` `BuyInBrowserButton`: open the Redeem code screen beside the browser only when not registered (replaces `appPlatform.isDesktop`), and update its comment.
|
||||
|
||||
6. **Packaging**
|
||||
- `desktop/build.gradle.kts`: add `CFBundleURLTypes` (`CFBundleURLName` `chat.simplex.app`, scheme `simplexchat`) to the existing `extraKeysRawXml`.
|
||||
- `scripts/desktop/make-deb-linux.sh`: after `dpkg-deb -R`, sed `extracted/opt/*imple*/lib/*.desktop` so `Exec` ends in ` %u` and `MimeType=x-scheme-handler/simplexchat;`, and fail if the MimeType line is missing. This must run before the timestamp `touch`.
|
||||
- `desktop/src/jvmMain/resources/distribute/SimpleX.desktop` (AppImage only): add the MimeType line and `%u` to Exec. In `scripts/desktop/make-appimage-linux.sh`, make the Exec sed replace only the program, so `%u` is kept.
|
||||
- `scripts/flatpak/chat.simplex.simplex.desktop`: add the MimeType line. Confirm the Flathub manifest takes this file.
|
||||
|
||||
7. **Window activation** (`WindowActivation.kt`, `DesktopApp.kt showWindow`): `showWindow` skips a disposed window (during a crash restart), sets `isVisible` immediately, and on Linux also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA after syncing AWT's connection, because AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops.
|
||||
|
||||
8. **Docs**
|
||||
- `apps/multiplatform/spec/client/navigation.md`: replace "Desktop registers no URL scheme" with the desktop paths.
|
||||
- `apps/multiplatform/spec/architecture.md` and `spec/README.md`: the desktop `main()` steps, `showWindow()` and the line references they shift.
|
||||
- `spec/impact.md` and the `CODE.md` Document Map: rows for the new desktop files, with `Main.kt` and `SingleInstance.kt` mapped to `navigation.md` as well; the Source Files tables in `navigation.md` and `architecture.md`; `product/concepts.md`: a row for `AppLinks.kt`; `product/gaps.md`: GAP-08 for the known registration gaps, marked `[GAP]` in `navigation.md`.
|
||||
- `plans/2026-09-24-badge-buy-in-browser.md`: mark the desktop deep links section as done.
|
||||
|
||||
## Commits
|
||||
|
||||
1. `tests: close the walk stream in withTempDir`
|
||||
2. `desktop: receive app links from args and macOS`
|
||||
3. `desktop: forward an app link to the running app`
|
||||
4. `desktop: initialize the desktop platform lazily`
|
||||
5. `tests: share the temp directory helper`
|
||||
6. `desktop: register simplexchat scheme in packages`
|
||||
7. `desktop: register simplexchat scheme at runtime`
|
||||
8. `ui: end the badge page with a link on desktop`
|
||||
9. `desktop: raise the window over other apps`
|
||||
10. `docs: describe desktop deep links in the spec`
|
||||
11. `docs: plan desktop deep links`
|
||||
12. `docs: report on desktop deep links`
|
||||
13. `desktop: open simplex connection links`
|
||||
14. `docs: describe simplex links on desktop`
|
||||
15. `desktop: keep a link from opening twice`
|
||||
16. `desktop: retry taking a signal held open`
|
||||
17. `desktop: keep the user's AppImage link handler`
|
||||
18. `desktop: register Windows links through Capabilities`
|
||||
19. `desktop: add %u to the deb entry only once`
|
||||
20. `docs: describe the review fixes`
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit tests** (`common/src/desktopTest`, beside `SingleInstanceTest.kt`):
|
||||
- `linkFromArgs`: accepts `simplexchat:` in any case; rejects `https:`, empty args, a link that is not the only argument, and a link over the bound in bytes, including a multi-byte one within it in characters.
|
||||
- Signal payload: link and empty round trip, taken once, replace of an untaken signal and of a taken file left by a crash, owner-only file on POSIX, a link of exactly the byte bound, rejected content including a multi-byte link over the bound, a temp file created beside the signal, a sweep that removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, keeps a signal within the 2 s tolerance and drops an older one, a watcher that takes a signal written before its watch, and successive signals arriving while it watches, each once, through a real `WatchService`.
|
||||
- The Windows command string, the registry values and registration with a stand-in registry, the deb's registered check, the AppImage desktop entry for a path with spaces, Exec quoting that leaves a plain path bare and quotes and escapes reserved characters, its refusal of line breaks and `%`, `runProcess` output, discarded error output, an end of input and failure, and `desktopInstallation` precedence including Flatpak over AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration with a stand-in for `xdg-mime`: a new entry in a missing directory, an unchanged default, an unchanged entry that is no longer the default, a moved AppImage, a default that does not change, and a path the entry cannot run.
|
||||
- `badgePageUrl` (in `common/src/commonTest`): `app=true` when the link returns, `app=desktop` otherwise.
|
||||
- `activationEvent` and `sendActivation`: a `_NET_ACTIVE_WINDOW` client message for the window, with the pager source and `CurrentTime`, checked in native memory, sent to the root window through a stand-in for libX11 that also checks the display is closed.
|
||||
- **Commands**: `./gradlew :common:desktopTest`, `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`, with no new warnings.
|
||||
- **Manual matrix** on packaged builds. States: app closed, running, hidden in tray, locked with passcode, no active profile.
|
||||
- Launch commands: `open 'simplexchat:/badge/code/<code>'`, `start simplexchat:/badge/code/<code>`, `xdg-open ...`.
|
||||
- End to end through the page mock (`apps/simplex-badge-service/web/mock/server.py`, `#/tier?app=true`, settle via `/control/settle/<invoiceId>`) in Chrome, Firefox and Safari.
|
||||
- Linux: deb on GNOME and KDE, snap Firefox, Flatpak, AppImage.
|
||||
- Check `lsregister -dump | grep simplexchat` on macOS and the HKCU key on Windows.
|
||||
- Confirm that no log contains the code.
|
||||
- **Unpackaged `./gradlew run`**: reports not registered and keeps D1.
|
||||
|
||||
## Risks to confirm on hardware
|
||||
|
||||
- How the Windows launcher re-quotes argv when it restarts itself.
|
||||
- macOS cold start with Compose 1.8.2.
|
||||
- Snap Firefox on Ubuntu opening custom schemes.
|
||||
- GNOME Wayland raising the window (best effort).
|
||||
- Stale HKCU keys and AppImage entries stay after uninstall or deletion; the plan accepts that.
|
||||
- The deb's existing `Categories=Unknown`, which `desktop-file-validate` rejects, is out of scope and left as is.
|
||||
|
||||
## `simplex:` connection links
|
||||
|
||||
Android already opens `simplex:` links through `connectIfOpenedViaUri`, which sends them to `planAndConnect` (it asks before connecting). Desktop now receives them by the same paths as app links.
|
||||
|
||||
- `isAcceptedLink` accepts `isAppLink` or `isConnectionLink` (`ChatListView.kt`). One bound covers both: `MAX_LINK_BYTES` is 8192, as a one-time link with its post-quantum key is about 2 KB and a second such key would bring it near 4 KB (the sntrup761 key alone is 1544 characters in base64url). The desktop names lose the `App` part: `linkFromArgs`, `openDesktopLink`, `registerLinkSchemes`.
|
||||
- Every registration declares both schemes: the Info.plist, the deb, AppImage and Flatpak desktop entries, an HKCU key per scheme on Windows, and `xdg-mime default` per MIME type for an AppImage.
|
||||
- `appLinkSchemeRegistered()` still reports `simplexchat:` only, as it decides how the badge page ends; `simplex:` is registered without being checked.
|
||||
- Tests: a short link, a one-time link with its key, and the scheme in another case are accepted; another scheme sharing the prefix and a path are not; a forwarded one-time link round trips through the signal file; Windows and AppImage registration write both schemes, and only `simplexchat:` decides the result.
|
||||
|
||||
## Review fixes
|
||||
|
||||
- "Start anyway" after the not-responding alert withdraws the signal with `deleteIfExists`; when the running instance has already taken it, the second process exits, so the link opens once (`startDespiteRunningInstance`, extracted from `acquireSingleInstance` so it is tested).
|
||||
- `takeSignal` retries a failed rename once after 100 ms, as another program (an antivirus scanner on Windows) can hold the file open for a moment. The move is a parameter, so tests make it fail.
|
||||
- Windows registration compares and repairs every value, not only the command; reads a value of another type as missing instead of letting JNA's `RuntimeException` end the registration thread; and reports not registered when `UserChoice` names another ProgId (replaced later by asking the shell, below).
|
||||
- Registration takes a scheme only when no handler is set or the current one is no longer installed: on Windows when the command's program file is missing, for an AppImage when the current default's desktop entry is in no applications directory. Another installation keeps it, and the badge page falls back to `app=desktop`.
|
||||
- The deb's `Exec` sed adds `%u` only to a line without a field code.
|
||||
- Windows: a test on Windows 11 showed that a scheme key alone loses to any app registered through `Capabilities`: links went to that app without asking, and default-app settings did not offer SimpleX. Each scheme now also gets a `SimpleX.<scheme>` ProgId named in `HKCU\Software\chat.simplex.app\Capabilities\URLAssociations`, and the application is listed in `HKCU\Software\RegisteredApplications` last. `UserChoice` was compared with that ProgId until the shell check below replaced it. The scheme key stays, as it declares the URL scheme to Windows.
|
||||
- Not changed: stale-signal detection still compares file times with the local clock (network file systems with clock skew above 2 s are an accepted edge case).
|
||||
- A second review of the Windows change: Windows 11 can keep the user's pick in `UserChoiceLatest` and stop updating `UserChoice`, and with several capable apps and no pick Windows chooses or asks. So the registered check no longer reads either key: it asks the shell which program opens `simplexchat:` links (`AssocQueryStringW` with `ASSOCSTR_EXECUTABLE`) and compares it with this exe. This app's own ProgId is rewritten even while another program holds the scheme, so a pick in settings keeps working after a reinstall elsewhere; the "another program" check also reads `HKLM\Software\Classes`, which a per-user key would otherwise hide; the application is listed only when at least one scheme is this app's; and `SHChangeNotify(SHCNE_ASSOCCHANGED)` follows any write. Both functions are called through `NativeLibrary`, as jna-platform 5.14 binds neither.
|
||||
- A third review: `SHChangeNotify` also clears Explorer's icon cache, so it is sent only after a write. Another installed copy's ProgId (its command names an existing exe other than this one) is kept like any other handler, so two installed copies do not take the user's pick from each other at every start. A command naming this exe with other arguments or letter case counts as this app's own and is updated. A ProgId that cannot be written keeps its scheme unregistered, so `URLAssociations` never names a missing ProgId.
|
||||
@@ -39,7 +39,7 @@ cp usr/lib/simplex.png usr/share/icons
|
||||
|
||||
ln -s usr/bin/*imple* AppRun
|
||||
cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*imple*.desktop chat.simplex.app.desktop
|
||||
sed -i 's|Exec=.*|Exec=simplex|g' *imple*.desktop
|
||||
sed -i 's|^Exec=[^ ]*|Exec=simplex|' *imple*.desktop
|
||||
sed -i 's|Icon=.*|Icon=simplex|g' *imple*.desktop
|
||||
cp *imple*.desktop usr/share/applications/
|
||||
cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*.appdata.xml usr/share/metainfo
|
||||
|
||||
@@ -48,6 +48,10 @@ dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted
|
||||
rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux*
|
||||
sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg
|
||||
sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control
|
||||
# jpackage's desktop entry passes no URL and handles no MIME type; this registers the simplexchat: and simplex: schemes
|
||||
SCHEME_MIME_TYPE_LINE='MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;'
|
||||
sed -i -e '/%[uU]/!s|^Exec=.*|& %u|' -e "s|^MimeType=.*|$SCHEME_MIME_TYPE_LINE|" ./extracted/opt/*imple*/lib/*.desktop
|
||||
grep -qxF "$SCHEME_MIME_TYPE_LINE" ./extracted/opt/*imple*/lib/*.desktop || { echo "desktop entry does not register simplexchat: and simplex:" >&2; exit 1; }
|
||||
find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} +
|
||||
|
||||
dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb
|
||||
|
||||
@@ -8,3 +8,4 @@ Categories=Utility;Chat;InstantMessaging;
|
||||
Exec=simplex %U
|
||||
Icon=chat.simplex.simplex
|
||||
StartupWMClass=simplex-chat
|
||||
MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;
|
||||
|
||||
Reference in New Issue
Block a user