diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 26bfe6940..c9908f325 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.reorderable) implementation(libs.platformtools.darkmodedetector) implementation(libs.jna) + implementation("it.sauronsoftware:junique:1.0.4") } compose.desktop { @@ -44,7 +45,7 @@ compose.desktop { mainClass = "dev.dimension.flare.MainKt" nativeDistributions { - targetFormats(TargetFormat.Pkg, TargetFormat.Exe) + targetFormats(TargetFormat.Pkg, TargetFormat.Exe, TargetFormat.Deb) packageName = "Flare" val buildVersion = System.getenv("BUILD_VERSION")?.toString()?.takeIf { // match semantic versioning @@ -84,7 +85,7 @@ compose.desktop { iconFile.set(project.file("resources/ic_launcher.ico")) } linux { - modules("jdk.security.auth") + iconFile.set(project.file("resources/ic_launcher.png")) } appResourcesRootDir.set(file("resources")) } diff --git a/desktopApp/resources/ic_launcher.png b/desktopApp/resources/ic_launcher.png new file mode 100644 index 000000000..4c4c8a6a8 Binary files /dev/null and b/desktopApp/resources/ic_launcher.png differ diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 4aa0b3e0d..765267ec7 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -1,8 +1,6 @@ package dev.dimension.flare import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key @@ -16,7 +14,9 @@ import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade +import dev.dimension.flare.common.APPSCHEMA import dev.dimension.flare.common.DeeplinkHandler +import dev.dimension.flare.common.FlareWindowManager import dev.dimension.flare.common.NativeWindowBridge import dev.dimension.flare.common.NoopIPC import dev.dimension.flare.common.SandboxHelper @@ -31,15 +31,29 @@ import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideThemeSettings import io.github.kdroidfilter.platformtools.darkmodedetector.windows.setWindowsAdaptiveTitleBar +import it.sauronsoftware.junique.AlreadyLockedException +import it.sauronsoftware.junique.JUnique import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.context.startKoin +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import java.awt.Desktop +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.absolutePathString fun main(args: Array) { + if (SystemUtils.IS_OS_LINUX && isRunning(args)) { + return + } + if (SystemUtils.IS_OS_LINUX) { + ensureMimeInfo() + ensureDesktopEntry() + } SandboxHelper.configureSandboxArgs() val ports = WindowsIPC.parsePorts(args) val platformIPC = @@ -58,6 +72,7 @@ fun main(args: Array) { desktopModule + KoinHelper.modules() + composeUiModule + module { single { platformIPC } + singleOf(::FlareWindowManager) }, ) } @@ -82,7 +97,7 @@ fun main(args: Array) { }.crossfade(true) .build() } - val extraWindowRoutes = remember { mutableStateMapOf() } + val extraWindowRoutes = koinInject() val nativeWindowBridge = koinInject() fun openWindow( @@ -116,7 +131,9 @@ fun main(args: Array) { size = DpSize(520.dp, 840.dp), ), ) { - window.setWindowsAdaptiveTitleBar() + if (SystemUtils.IS_OS_WINDOWS) { + window.setWindowsAdaptiveTitleBar() + } FlareTheme { FlareApp( onWindowRoute = { @@ -170,3 +187,58 @@ fun main(args: Array) { } } } + +private const val ENTRY_FILE_NAME = "flare.desktop" +private const val LOCK_ID = "dev.dimensiondev.flare" + +private fun ensureDesktopEntry() { + val entryFile = + File("${System.getProperty("user.home")}/.local/share/applications/$ENTRY_FILE_NAME") + if (!entryFile.exists()) { + entryFile.createNewFile() + } + val path = Files.readSymbolicLink(Paths.get("/proc/self/exe")) + entryFile.writeText( + "[Desktop Entry]${System.lineSeparator()}" + + "Type=Application${System.lineSeparator()}" + + "Name=Flare${System.lineSeparator()}" + + "Icon=\"${path.parent.parent.absolutePathString() + "/lib/Flare.png" + "\""}${System.lineSeparator()}" + + "Exec=\"${path.absolutePathString() + "\" %u"}${System.lineSeparator()}" + + "Terminal=false${System.lineSeparator()}" + + "Categories=Network;Internet;${System.lineSeparator()}" + + "MimeType=application/x-$APPSCHEMA;x-scheme-handler/$APPSCHEMA;", + ) +} + +private fun ensureMimeInfo() { + val file = File("${System.getProperty("user.home")}/.local/share/applications/mimeinfo.cache") + if (!file.exists()) { + file.createNewFile() + } + val text = file.readText() + if (text.isEmpty() || text.isBlank()) { + file.writeText("[MIME Cache]${System.lineSeparator()}") + } + if (!file.readText().contains("x-scheme-handler/$APPSCHEMA=$ENTRY_FILE_NAME;")) { + file.appendText("${System.lineSeparator()}x-scheme-handler/$APPSCHEMA=$ENTRY_FILE_NAME;") + } +} + +private fun isRunning(args: Array): Boolean { + val running = + try { + JUnique.acquireLock(LOCK_ID) { + DeeplinkHandler.handleDeeplink(it) + null + } + false + } catch (e: AlreadyLockedException) { + true + } + if (running) { + args.forEach { + JUnique.sendMessage(LOCK_ID, it) + } + } + return running +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/FlareWindowManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/FlareWindowManager.kt new file mode 100644 index 000000000..33fd07605 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/FlareWindowManager.kt @@ -0,0 +1,39 @@ +package dev.dimension.flare.common + +import androidx.compose.runtime.mutableStateMapOf +import dev.dimension.flare.ui.route.FloatingWindowState +import dev.dimension.flare.ui.route.Route + +internal class FlareWindowManager { + private val windows = mutableStateMapOf() + + fun containsKey(key: String): Boolean = windows.containsKey(key) + + operator fun get(key: String): FloatingWindowState? = windows[key] + + fun put( + key: String, + state: FloatingWindowState, + ) { + windows[key] = state + } + + fun put( + key: String, + route: Route.WindowRoute, + ) { + windows[key] = FloatingWindowState(route) + } + + fun remove(key: String) { + windows.remove(key) + } + + fun clear() { + windows.clear() + } + + inline fun forEach(action: (Map.Entry) -> Unit) { + windows.forEach(action) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/NativeWindowBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/NativeWindowBridge.kt index 09255806b..42db7bb41 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/NativeWindowBridge.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/NativeWindowBridge.kt @@ -3,7 +3,6 @@ package dev.dimension.flare.common import dev.dimension.flare.common.macos.MacosBridge import dev.dimension.flare.common.windows.WindowsBridge import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.route.Route @@ -18,12 +17,15 @@ import org.apache.commons.lang3.SystemUtils internal class NativeWindowBridge( private val scope: CoroutineScope, private val windowsBridge: WindowsBridge, + private val windowManager: FlareWindowManager, ) { fun openImageImageViewer(url: String) { if (SystemUtils.IS_OS_MAC_OSX) { MacosBridge.openImageViewer(url) } else if (SystemUtils.IS_OS_WINDOWS) { windowsBridge.openImageViewer(url) + } else if (SystemUtils.IS_OS_LINUX) { + windowManager.put(url, Route.RawImage(url)) } else { // TODO: Implement for other platforms } @@ -60,6 +62,17 @@ internal class NativeWindowBridge( statusKey = statusKey, userHandle = userHandle, ) + } else if (SystemUtils.IS_OS_LINUX) { + windowManager.put( + key = "${route.statusKey}-${route.index}", + route = + Route.StatusMedia( + statusKey = route.statusKey, + index = route.index, + accountType = route.accountType, + preview = route.preview, + ), + ) } else { // TODO: Implement for other platforms } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt index 0e383071e..6a28efd24 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt @@ -2,10 +2,12 @@ package dev.dimension.flare.common import dev.dimension.flare.common.macos.MacosBridge import dev.dimension.flare.common.windows.WindowsBridge +import dev.dimension.flare.ui.route.Route import org.apache.commons.lang3.SystemUtils internal class WebViewBridge( private val windowsBridge: WindowsBridge, + private val windowManager: FlareWindowManager, ) { fun openAndWaitCookies( url: String, @@ -27,6 +29,19 @@ internal class WebViewBridge( callback(it) }, ) + } else if (SystemUtils.IS_OS_LINUX) { + windowManager.put( + key = url, + route = + Route.WebViewLogin( + url = url, + cookieCallback = { + if (callback(it)) { + windowManager.remove(url) + } + }, + ), + ) } else { // TODO: Implement for other platforms } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index e65f2823f..115ef4c90 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -195,6 +195,11 @@ internal sealed interface Route { val userKey: MicroBlogKey, ) : ScreenRoute + data class WebViewLogin( + val url: String, + val cookieCallback: ((cookies: String?) -> Unit)?, + ) : WindowRoute + companion object { public fun parse(url: String): Route? { val deeplinkRoute = DeeplinkRoute.parse(url) ?: return null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 6be175df3..9adcd3bd5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -49,6 +49,7 @@ import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import dev.dimension.flare.ui.screen.settings.LocalCacheScreen import dev.dimension.flare.ui.screen.settings.SettingsScreen +import dev.dimension.flare.ui.screen.settings.WebViewLoginScreen import dev.dimension.flare.ui.screen.status.StatusScreen import dev.dimension.flare.ui.screen.status.VVOCommentScreen import dev.dimension.flare.ui.screen.status.VVOStatusScreen @@ -613,5 +614,8 @@ internal fun WindowScope.RouteContent( ) }, ) + + is Route.WebViewLogin -> + WebViewLoginScreen(route = route) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt new file mode 100644 index 000000000..f5d854735 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt @@ -0,0 +1,8 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.runtime.Composable +import dev.dimension.flare.ui.route.Route + +@Composable +internal fun WebViewLoginScreen(route: Route.WebViewLogin) { +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d5c3d6026..127fdbd2d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://central.sonatype.com/repository/maven-snapshots/") + maven("https://github.com/poolborges/maven/raw/master/thirdparty/") } }