Files
amethyst/.claude/skills/desktop-expert/references/os-detection.md
T
2025-12-30 15:18:48 +02:00

14 KiB

OS Detection & Platform-Specific Code

Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.

OS Detection

Basic Detection

val osName = System.getProperty("os.name").lowercase()

val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")

System Properties

// OS name
System.getProperty("os.name")
// Examples: "Mac OS X", "Windows 10", "Linux"

// OS version
System.getProperty("os.version")
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"

// OS architecture
System.getProperty("os.arch")
// Examples: "aarch64", "x86_64", "amd64"

// User home directory
System.getProperty("user.home")
// Examples: "/Users/username", "C:\Users\username", "/home/username"

// File separator
System.getProperty("file.separator")
// Examples: "/" (Unix), "\" (Windows)

// Path separator
System.getProperty("path.separator")
// Examples: ":" (Unix), ";" (Windows)

PlatformDetector Utility

Create a centralized utility for platform detection.

File: commons/src/jvmMain/kotlin/utils/PlatformDetector.kt

package com.vitorpamplona.amethyst.commons.utils

object PlatformDetector {
    private val osName = System.getProperty("os.name").lowercase()

    val isMacOS: Boolean = osName.contains("mac")
    val isWindows: Boolean = osName.contains("win")
    val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")

    val platform: Platform = when {
        isMacOS -> Platform.MacOS
        isWindows -> Platform.Windows
        isLinux -> Platform.Linux
        else -> Platform.Unknown
    }

    enum class Platform {
        MacOS,
        Windows,
        Linux,
        Unknown
    }

    // File paths
    val fileSeparator: String = System.getProperty("file.separator")
    val pathSeparator: String = System.getProperty("path.separator")

    // User directories
    val userHome: String = System.getProperty("user.home")

    val appDataDir: String = when (platform) {
        Platform.MacOS -> "$userHome/Library/Application Support"
        Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
        Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
        Platform.Unknown -> userHome
    }

    // Modifier key names
    val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
    val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"

    fun platformSpecific(
        macOS: () -> Unit = {},
        windows: () -> Unit = {},
        linux: () -> Unit = {},
        fallback: () -> Unit = {}
    ) {
        when (platform) {
            Platform.MacOS -> macOS()
            Platform.Windows -> windows()
            Platform.Linux -> linux()
            Platform.Unknown -> fallback()
        }
    }
}

Usage:

// Simple check
if (PlatformDetector.isMacOS) {
    // macOS-specific code
}

// Pattern matching
when (PlatformDetector.platform) {
    Platform.MacOS -> setupMacDock()
    Platform.Windows -> setupWindowsTray()
    Platform.Linux -> setupLinuxTray()
    Platform.Unknown -> showWarning()
}

// Platform-specific execution
PlatformDetector.platformSpecific(
    macOS = { setupMacMenuBar() },
    windows = { setupWindowsMenu() },
    linux = { setupLinuxMenu() }
)

// File paths
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"

Platform-Specific UI

Keyboard Shortcuts Helper

package com.vitorpamplona.amethyst.commons.utils

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut

object DesktopShortcuts {
    private val isMacOS = PlatformDetector.isMacOS

    fun primary(key: Key) = if (isMacOS) {
        KeyShortcut(key, meta = true)
    } else {
        KeyShortcut(key, ctrl = true)
    }

    fun primaryShift(key: Key) = if (isMacOS) {
        KeyShortcut(key, meta = true, shift = true)
    } else {
        KeyShortcut(key, ctrl = true, shift = true)
    }

    fun primaryAlt(key: Key) = if (isMacOS) {
        KeyShortcut(key, meta = true, alt = true)
    } else {
        KeyShortcut(key, ctrl = true, alt = true)
    }

    val modifierName = PlatformDetector.primaryModifierName
    val secondaryName = PlatformDetector.secondaryModifierName

    fun formatShortcut(key: String, withPrimary: Boolean = true): String {
        return if (withPrimary) "$modifierName+$key" else key
    }
}

File Paths Helper

package com.vitorpamplona.amethyst.commons.utils

import java.io.File

object FilePaths {
    private val separator = PlatformDetector.fileSeparator

    fun join(vararg parts: String): String {
        return parts.joinToString(separator)
    }

    fun appConfig(appName: String): String {
        return join(PlatformDetector.appDataDir, appName)
    }

    fun appCache(appName: String): String {
        return when (PlatformDetector.platform) {
            PlatformDetector.Platform.MacOS ->
                join(PlatformDetector.userHome, "Library", "Caches", appName)
            PlatformDetector.Platform.Windows ->
                join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
            PlatformDetector.Platform.Linux ->
                join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
            else -> join(PlatformDetector.userHome, ".cache", appName)
        }
    }

    fun ensureDirectory(path: String): File {
        return File(path).apply {
            if (!exists()) {
                mkdirs()
            }
        }
    }
}

// Usage
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))

Platform-Specific Features

Open External URL

// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
expect fun openExternalUrl(url: String)

// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
import java.awt.Desktop
import java.net.URI

actual fun openExternalUrl(url: String) {
    if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
        Desktop.getDesktop().browse(URI(url))
    }
}

File Picker

// Platform-specific file picker
fun showFilePicker(
    title: String = "Select file",
    mode: FilePickerMode = FilePickerMode.Load
): String? {
    val fileDialog = java.awt.FileDialog(
        java.awt.Frame(),
        title,
        when (mode) {
            FilePickerMode.Load -> java.awt.FileDialog.LOAD
            FilePickerMode.Save -> java.awt.FileDialog.SAVE
        }
    )

    // macOS-specific: Enable file selection features
    if (PlatformDetector.isMacOS) {
        System.setProperty("apple.awt.fileDialogForDirectories", "false")
    }

    fileDialog.isVisible = true

    return fileDialog.file?.let { "${fileDialog.directory}$it" }
}

enum class FilePickerMode {
    Load,
    Save
}

Directory Picker (macOS)

fun showDirectoryPicker(title: String = "Select directory"): String? {
    if (PlatformDetector.isMacOS) {
        // macOS-specific directory picker
        System.setProperty("apple.awt.fileDialogForDirectories", "true")
    }

    val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
    fileDialog.isVisible = true

    if (PlatformDetector.isMacOS) {
        System.setProperty("apple.awt.fileDialogForDirectories", "false")
    }

    return fileDialog.directory
}

Window Decorations

macOS-Specific

// Unified title bar (macOS Big Sur+)
if (PlatformDetector.isMacOS) {
    Window(
        undecorated = false,
        transparent = true,
        // ...
    ) {
        // Custom title bar
    }
}

Windows-Specific

// Custom window chrome (Windows)
if (PlatformDetector.isWindows) {
    Window(
        undecorated = true,
        // Custom decorations
    ) {
        Column {
            // Custom title bar with min/max/close buttons
            WindowTitleBar()
            // Content
        }
    }
}

System Tray Icons

Different icon formats per OS:

fun getTrayIcon(): Painter {
    return when (PlatformDetector.platform) {
        Platform.MacOS -> painterResource("tray-icon-mac.png")  // Template icon
        Platform.Windows -> painterResource("tray-icon-win.ico")
        Platform.Linux -> painterResource("tray-icon-linux.png")
        else -> painterResource("tray-icon.png")
    }
}

// macOS: Template icons (black/transparent)
// Windows: ICO format, 16x16
// Linux: PNG, typically 24x24

Native Notifications

// Platform-specific notification implementation
fun sendNotification(title: String, message: String) {
    PlatformDetector.platformSpecific(
        macOS = {
            // macOS: Use NSUserNotification (via tray)
            trayState.sendNotification(
                Notification(title, message, Notification.Type.Info)
            )
        },
        windows = {
            // Windows: Use Windows toast notifications
            trayState.sendNotification(
                Notification(title, message, Notification.Type.Info)
            )
        },
        linux = {
            // Linux: Use libnotify (via tray)
            trayState.sendNotification(
                Notification(title, message, Notification.Type.Info)
            )
        }
    )
}

Architecture Detection

object ArchDetector {
    private val arch = System.getProperty("os.arch").lowercase()

    val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
    val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
    val isX86: Boolean = arch.contains("x86") && !isX64

    val architecture: Architecture = when {
        isArm -> Architecture.ARM
        isX64 -> Architecture.X64
        isX86 -> Architecture.X86
        else -> Architecture.Unknown
    }

    enum class Architecture {
        ARM,
        X64,
        X86,
        Unknown
    }
}

// Usage: Load correct native library
fun loadNativeLib() {
    val libName = when {
        PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
        PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
        PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
        PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
        else -> throw UnsupportedOperationException("Unsupported platform")
    }

    System.loadLibrary(libName)
}

Testing Platform Detection

@Test
fun testPlatformDetection() {
    println("OS: ${System.getProperty("os.name")}")
    println("Version: ${System.getProperty("os.version")}")
    println("Arch: ${System.getProperty("os.arch")}")
    println()
    println("Is macOS: ${PlatformDetector.isMacOS}")
    println("Is Windows: ${PlatformDetector.isWindows}")
    println("Is Linux: ${PlatformDetector.isLinux}")
    println("Platform: ${PlatformDetector.platform}")
    println()
    println("User home: ${PlatformDetector.userHome}")
    println("App data: ${PlatformDetector.appDataDir}")
    println("File separator: ${PlatformDetector.fileSeparator}")
}

// Example output (macOS):
// OS: Mac OS X
// Version: 14.2.1
// Arch: aarch64
//
// Is macOS: true
// Is Windows: false
// Is Linux: false
// Platform: MacOS
//
// User home: /Users/username
// App data: /Users/username/Library/Application Support
// File separator: /

Best Practices

1. Centralize Detection

DO: Use PlatformDetector singleton

if (PlatformDetector.isMacOS) { /* ... */ }

DON'T: Repeat detection everywhere

if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }

2. Use expect/actual for Platform APIs

// commonMain
expect fun openFile(path: String)

// jvmMain (Desktop)
actual fun openFile(path: String) {
    Desktop.getDesktop().open(File(path))
}

// androidMain
actual fun openFile(path: String) {
    context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
}

3. Graceful Degradation

fun openBrowser(url: String) {
    try {
        if (Desktop.isDesktopSupported()) {
            Desktop.getDesktop().browse(URI(url))
        } else {
            // Fallback: Copy to clipboard
            Toolkit.getDefaultToolkit().systemClipboard.setContents(
                StringSelection(url),
                null
            )
            showMessage("URL copied to clipboard: $url")
        }
    } catch (e: Exception) {
        showError("Failed to open browser: ${e.message}")
    }
}

4. Test on All Platforms

Always test platform-specific code on:

  • macOS (Intel + Apple Silicon if possible)
  • Windows (10/11)
  • Linux (Ubuntu/Fedora)

Common Patterns

Pattern: Config File Location

fun getConfigFile(filename: String): File {
    val configDir = when (PlatformDetector.platform) {
        Platform.MacOS ->
            File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
        Platform.Windows ->
            File("${System.getenv("APPDATA")}\\Amethyst")
        Platform.Linux ->
            File("${PlatformDetector.userHome}/.config/amethyst")
        else ->
            File("${PlatformDetector.userHome}/.amethyst")
    }

    if (!configDir.exists()) {
        configDir.mkdirs()
    }

    return File(configDir, filename)
}

// Usage
val settingsFile = getConfigFile("settings.json")

Pattern: Platform-Specific Resources

fun getPlatformIcon(name: String): Painter {
    val extension = when (PlatformDetector.platform) {
        Platform.MacOS -> "icns"
        Platform.Windows -> "ico"
        else -> "png"
    }

    return painterResource("$name.$extension")
}

// Resources:
// src/jvmMain/resources/app-icon.icns (macOS)
// src/jvmMain/resources/app-icon.ico (Windows)
// src/jvmMain/resources/app-icon.png (Linux)

References