diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt new file mode 100644 index 0000000..0c523d6 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt @@ -0,0 +1,134 @@ +package ru.fromchat.desktop + +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import com.sun.jna.platform.win32.User32 +import com.sun.jna.platform.win32.WinUser +import java.awt.Rectangle +import java.awt.Window +import kotlin.math.abs + +internal fun windowsWorkAreaBounds(window: Window): Rectangle? { + if (!isWindowsOs()) return null + val hwnd = window.windowsHwnd() + if (hwnd.pointer == null) return null + val monitor = User32.INSTANCE.MonitorFromWindow(hwnd, WinUser.MONITOR_DEFAULTTONEAREST) + ?: return null + val info = WinUser.MONITORINFO() + if (!User32.INSTANCE.GetMonitorInfo(monitor, info).booleanValue()) return null + val work = info.rcWork + if (work.right <= work.left || work.bottom <= work.top) return null + return Rectangle( + work.left, + work.top, + work.right - work.left, + work.bottom - work.top, + ) +} + +/** Keeps a floating window inside the monitor work area (excludes taskbar). */ +internal fun clampFloatingAwtWindowToWorkArea(window: Window): Boolean { + if (!isWindowsOs()) return false + if (window.isNativeZoomed()) return false + val work = windowsWorkAreaBounds(window) ?: return false + val current = window.bounds + val clamped = clampRectangleToWorkArea(current, work) + if (clamped == current) return false + window.setBounds(clamped) + return true +} + +internal fun applyFloatingGeometry( + window: Window, + windowState: WindowState, + geometry: DesktopFloatingGeometry, + density: Density, +) { + applyFloatingGeometry(window, windowState, geometry.size, geometry.position, density) +} + +internal fun applyFloatingGeometry( + window: Window, + windowState: WindowState, + size: DpSize, + position: WindowPosition, + density: Density, +) { + with(density) { + val widthPx = size.width.roundToPx().coerceAtLeast(1) + val heightPx = size.height.roundToPx().coerceAtLeast(1) + val (x, y) = when (position) { + is WindowPosition.Absolute -> { + position.x.roundToPx() to position.y.roundToPx() + } + else -> window.location.x to window.location.y + } + window.setBounds(x, y, widthPx, heightPx) + windowState.size = size + if (position is WindowPosition.Absolute) { + windowState.position = position + } + } +} + +internal fun applySavedFloatingGeometry( + window: Window, + windowState: WindowState, + density: Density, +) { + applyFloatingGeometry(window, windowState, DesktopWindowPrefs.loadFloatingGeometry(), density) +} + +internal fun captureFloatingGeometryToPrefs( + window: Window, + windowState: WindowState, + density: Density, +) { + if (windowState.placement != WindowPlacement.Floating || window.isNativeZoomed()) return + val bounds = window.bounds + with(density) { + DesktopWindowPrefs.saveFloatingGeometry( + size = DpSize(bounds.width.toDp(), bounds.height.toDp()), + position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()), + ) + } +} + +internal fun syncComposeWindowStateFromAwt( + window: Window, + windowState: WindowState, + density: Density, +) { + if (windowState.placement != WindowPlacement.Floating) return + val bounds = window.bounds + with(density) { + windowState.size = DpSize(bounds.width.toDp(), bounds.height.toDp()) + windowState.position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()) + } +} + +internal fun floatingWindowCoversWorkArea(window: Window, tolerancePx: Int = 8): Boolean { + val work = windowsWorkAreaBounds(window) ?: return false + val bounds = window.bounds + return abs(bounds.x - work.x) <= tolerancePx && + abs(bounds.y - work.y) <= tolerancePx && + abs(bounds.width - work.width) <= tolerancePx && + abs(bounds.height - work.height) <= tolerancePx +} + +private fun clampRectangleToWorkArea(bounds: Rectangle, work: Rectangle): Rectangle { + val width = bounds.width.coerceAtMost(work.width).coerceAtLeast(320) + val height = bounds.height.coerceAtMost(work.height).coerceAtLeast(240) + val maxX = (work.x + work.width - width).coerceAtLeast(work.x) + val maxY = (work.y + work.height - height).coerceAtLeast(work.y) + return Rectangle( + bounds.x.coerceIn(work.x, maxX), + bounds.y.coerceIn(work.y, maxY), + width, + height, + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DeviceSessionDisplay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DeviceSessionDisplay.kt new file mode 100644 index 0000000..123eb96 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DeviceSessionDisplay.kt @@ -0,0 +1,244 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LaptopMac +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource +import ru.fromchat.Res +import ru.fromchat.api.schema.user.devices.DeviceSessionInfo +import ru.fromchat.os_linux +import ru.fromchat.os_macos +import ru.fromchat.os_windows + +private val PLACEHOLDER_VALUES = setOf("other", "unknown", "null", "n/a", "na", "-") + +internal fun String?.sanitizeDeviceField(): String? { + val trimmed = this?.trim() ?: return null + if (trimmed.isEmpty()) return null + if (trimmed.lowercase() in PLACEHOLDER_VALUES) return null + return trimmed +} + +internal fun DeviceSessionInfo.isDesktopSession(): Boolean { + val type = deviceType?.lowercase().orEmpty() + if (type in setOf("mobile", "tablet", "phone")) return false + if (type == "desktop") return true + val os = resolveDeviceOsName(this)?.lowercase().orEmpty() + return os in setOf("windows", "macos", "linux") && !shouldShowBrowser() +} + +internal fun DeviceSessionInfo.isMobileSession(): Boolean { + val type = deviceType?.lowercase().orEmpty() + return type in setOf("mobile", "tablet", "phone") +} + +internal fun DeviceSessionInfo.shouldShowBrowser(): Boolean { + val browser = effectiveBrowserName() ?: return false + val type = deviceType?.lowercase().orEmpty() + if (type in setOf("desktop", "mobile", "tablet", "phone")) return false + if (isDesktopSession() || isMobileSession()) return false + if (type in setOf("browser", "web")) return true + return isMeaningfulBrowserName(browser) +} + +internal fun DeviceSessionInfo.effectiveBrowserName(): String? = + browserName.sanitizeDeviceField()?.takeUnless { isPlaceholderBrowser(it) } + +internal fun DeviceSessionInfo.effectiveBrowserVersion(): String? = + browserVersion.sanitizeDeviceField() + +internal fun DeviceSessionInfo.effectiveBrowserLine(): String? { + val name = effectiveBrowserName() ?: return null + val version = effectiveBrowserVersion() + return if (version == null) name else "$name $version" +} + +internal fun normalizeDeviceSession(d: DeviceSessionInfo): DeviceSessionInfo { + val withOs = d.copy( + deviceName = d.deviceName.sanitizeDeviceField(), + deviceType = d.deviceType?.trim()?.takeIf { it.isNotEmpty() }, + osName = resolveDeviceOsName(d), + osVersion = d.osVersion.sanitizeDeviceField(), + brand = d.brand.sanitizeDeviceField(), + model = d.model.sanitizeDeviceField()?.takeUnless { isHardwarePlaceholder(it) }, + ) + return withOs.copy( + browserName = if (withOs.shouldShowBrowser()) withOs.effectiveBrowserName() else null, + browserVersion = if (withOs.shouldShowBrowser()) withOs.effectiveBrowserVersion() else null, + ) +} + +internal fun deviceSessionForCurrentDevice(d: DeviceSessionInfo): DeviceSessionInfo { + if (!d.current) return normalizeDeviceSession(d) + val current = com.pr0gramm3r101.utils.currentDeviceInfo() + return normalizeDeviceSession( + d.copy( + osName = current.osName.sanitizeDeviceField() ?: d.osName, + osVersion = current.osVersion.sanitizeDeviceField() ?: d.osVersion, + deviceType = current.deviceType.sanitizeDeviceField() ?: d.deviceType, + deviceName = current.deviceName.sanitizeDeviceField() + ?: d.deviceName.sanitizeDeviceField(), + brand = current.brand.sanitizeDeviceField() ?: d.brand.sanitizeDeviceField(), + model = current.model.sanitizeDeviceField() ?: d.model.sanitizeDeviceField(), + browserName = null, + browserVersion = null, + ), + ) +} + +internal fun enrichDevicesList(list: List): List = + list.map { deviceSessionForCurrentDevice(it) } + +internal fun DeviceSessionInfo.isAppleSession(): Boolean { + val os = resolveDeviceOsName(this)?.lowercase().orEmpty() + return os == "macos" || os == "ios" +} + +internal fun deviceSessionHeadline(d: DeviceSessionInfo, unknownLabel: String): String { + if (d.isAppleSession()) { + return d.model?.takeUnless { isHardwarePlaceholder(it) } + ?: d.deviceName + ?: listOfNotNull(d.brand, d.model?.takeUnless { isHardwarePlaceholder(it) }) + .filter { it.isNotBlank() } + .joinToString(" ") + .takeIf { it.isNotBlank() } + ?: resolveDeviceOsName(d) + ?: unknownLabel + } + if (d.isDesktopSession()) { + return d.deviceName + ?: resolveDeviceOsName(d) + ?: unknownLabel + } + d.deviceName?.let { return it } + val brandModel = listOfNotNull(d.brand, d.model?.takeUnless { isHardwarePlaceholder(it) }) + .filter { it.isNotBlank() } + .joinToString(" ") + if (brandModel.isNotBlank()) return brandModel + val browser = d.effectiveBrowserName() + val os = resolveDeviceOsName(d) + if (browser != null && os != null) return "$browser on $os" + if (browser != null) return browser + return os ?: d.deviceType?.replaceFirstChar { it.uppercase() } ?: unknownLabel +} + +internal fun deviceSessionOsLine(d: DeviceSessionInfo): String? { + val os = resolveDeviceOsName(d) ?: return null + val version = d.osVersion.sanitizeDeviceField() + return if (version == null) os else "$os $version" +} + +internal fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector { + if (d.shouldShowBrowser()) return Icons.Rounded.Language + return when (resolveDeviceOsName(d)?.lowercase()) { + "android" -> Icons.Rounded.Android + "ios" -> Icons.Rounded.PhoneAndroid + "windows", "macos", "linux" -> Icons.Rounded.LaptopMac + else -> when { + d.isMobileSession() -> Icons.Rounded.PhoneAndroid + d.isDesktopSession() -> Icons.Rounded.LaptopMac + else -> Icons.Rounded.Language + } + } +} + +internal fun deviceSessionLogoResource(d: DeviceSessionInfo): DrawableResource? = + when (resolveDeviceOsName(d)?.lowercase()) { + "windows" -> Res.drawable.os_windows + "macos" -> Res.drawable.os_macos + "linux" -> Res.drawable.os_linux + else -> null + } + +internal fun resolveDeviceOsName(d: DeviceSessionInfo): String? { + val direct = normalizeDeviceOsName(d.osName) + if (direct != null) return direct + return normalizeDeviceOsName(inferDeviceOsFromSession(d)) +} + +private fun normalizeDeviceOsName(raw: String?): String? { + val trimmed = raw.sanitizeDeviceField() ?: return null + val lower = trimmed.lowercase() + return when { + "windows" in lower || lower == "win" || lower == "winnt" -> "Windows" + "mac" in lower || "os x" in lower || "darwin" in lower -> "macOS" + "linux" in lower -> "Linux" + "android" in lower -> "Android" + "ios" in lower || "iphone" in lower || "ipad" in lower -> "iOS" + else -> trimmed + } +} + +private fun inferDeviceOsFromSession(d: DeviceSessionInfo): String? { + val type = d.deviceType?.lowercase().orEmpty() + val name = d.deviceName?.lowercase().orEmpty() + val brand = d.brand?.lowercase().orEmpty() + val model = d.model?.lowercase().orEmpty() + val browser = d.browserName?.lowercase().orEmpty() + val isMobile = type in setOf("mobile", "tablet", "phone") + return when { + isMobile && ( + brand.contains("apple") || + name.contains("iphone") || + name.contains("ipad") || + model.contains("iphone") || + model.contains("ipad") || + browser.contains("ios") + ) -> "iOS" + isMobile && ( + brand.contains("android") || + name.contains("android") || + model.contains("android") || + browser.contains("android") + ) -> "Android" + isMobile && isAndroidBrand(brand) -> "Android" + "android" in browser || "android" in brand || "android" in model -> "Android" + brand.contains("apple") || + model.contains("iphone") || + model.contains("ipad") || + browser.contains("ios") || + browser.contains("iphone") || + browser.contains("ipad") -> "iOS" + type == "desktop" || name.contains("windows") || model.contains("windows") -> "Windows" + name.contains("mac") || model.contains("mac") -> "macOS" + else -> null + } +} + +private fun isAndroidBrand(brand: String): Boolean = + brand.contains("samsung") || + brand.contains("xiaomi") || + brand.contains("huawei") || + brand.contains("oppo") || + brand.contains("vivo") || + brand.contains("pixel") || + brand.contains("oneplus") || + brand.contains("google") || + brand.contains("lg") || + brand.contains("motorola") || + brand.contains("honor") || + brand.contains("realme") + +private fun isPlaceholderBrowser(name: String): Boolean { + val lower = name.lowercase() + return lower == "other" || lower == "unknown" +} + +private fun isMeaningfulBrowserName(name: String): Boolean { + val lower = name.lowercase() + return lower.contains("chrome") || + lower.contains("firefox") || + lower.contains("safari") || + lower.contains("edge") || + lower.contains("opera") || + lower.contains("brave") || + lower.contains("vivaldi") +} + +private fun isHardwarePlaceholder(value: String): Boolean { + val lower = value.lowercase() + return lower in setOf("amd64", "x86_64", "aarch64", "arm64", "i386", "x86", "device", "desktop") +}