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:
shandspaced4ndy authored and GitHub committed 2026-10-08 14:48:50 +00:00
1 parent b4f1249436
commit efab293609
35 files changed
+2219 -80

No files matched your search

@@ -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)
@@ -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
@@ -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.
@@ -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)
}
}
@@ -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) }
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
@@ -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()
}
}
@@ -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)
@@ -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))))
}
}
@@ -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) {}
}
}
}
}
@@ -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) }
}
}