mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 04:22:13 +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:
1 parent
b4f1249436
commit
efab293609
35 files changed
+2219
-80
No files matched your search
+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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user