头图

在开发跨平台桌面应用时,开机自启动是一个常见且重要的功能需求。本文将详细介绍如何使用 Kotlin Multiplatform (KMP) 实现 Windows、macOS 和 Linux 三大平台的开机自启动功能,包括接口设计、平台特性和具体实现。

所有源代码基于我开源项目 crosspaste-desktop,如果对你有帮助欢迎点个 star ❤️

1.设计

1.1 统一接口

为了实现跨平台的开机自启动功能,首先定义统一的接口:

AppStartUpService.kt

interface AppStartUpService {
    fun followConfig()               // 根据配置设置是否开机启动
    fun isAutoStartUp(): Boolean     // 检查是否已启用开机启动
    fun makeAutoStartUp()            // 启用开机启动
    fun removeAutoStartUp()          // 禁用开机启动
}

1.2 平台分发

创建工厂类动态选择对应平台的实现:

DesktopAppStartUpService.kt

class DesktopAppStartUpService(
    appLaunchState: DesktopAppLaunchState,
    configManager: ConfigManager,
) : AppStartUpService {
    private val currentPlatform = getPlatform()
    private val isProduction = getAppEnvUtils().isProduction()
    
    private val appStartUpService: AppStartUpService =
        when {
            currentPlatform.isMacos() -> MacAppStartUpService(configManager)
            currentPlatform.isWindows() -> WindowsAppStartUpService(appLaunchState, configManager)
            currentPlatform.isLinux() -> LinuxAppStartUpService(configManager)
            else -> throw IllegalStateException("Unsupported platform")
        }
        
    override fun followConfig() {
        if (isProduction) {
            appStartUpService.followConfig()
        }
    }
    
    // 其他方法实现...
}

2. macOS 实现

2.1 实现原理

macOS 使用 LaunchAgents 机制实现用户级自启动,这是 Apple 官方推荐的方案:

  • 配置文件位置:~/Library/LaunchAgents/
  • 作用域:仅对当前用户生效
  • 加载时机:用户登录后自动加载

2.2 具体实现

MacAppStartUpService

class MacAppStartUpService(private val configManager: ConfigManager) : AppStartUpService {
   // 使用 KotlinLogging 创建日志记录器
   private val logger: KLogger = KotlinLogging.logger {}
   
   // 从系统属性中获取应用的 Bundle ID,用作 plist 文件的唯一标识符
   private val crosspasteBundleID = getSystemProperty().get("mac.bundleID")
   // 生成 plist 文件名
   private val plist = "$crosspasteBundleID.plist"
   
   override fun makeAutoStartUp() {
       try {
           // 检查是否已经配置了自启动
           if (!isAutoStartUp()) {
               logger.info { "Make auto startup" }
               
               // 构建 plist 文件路径: ~/Library/LaunchAgents/xxx.plist
               // macOS 用户级自启动配置文件必须放在该目录下
               val plistPath = pathProvider.userHome.resolve("Library/LaunchAgents/$plist")
               
               // 创建并写入 plist 配置文件
               filePersist.createOneFilePersist(plistPath)
                   .saveBytes("""
                       <?xml version="1.0" encoding="UTF-8"?>
                       <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN">
                       <plist version="1.0">
                       <dict>
                           <!-- Label: 唯一标识符,用于系统识别该启动项 -->
                           <key>Label</key>
                           <string>$crosspasteBundleID</string>
                           
                           <!-- ProgramArguments: 指定要启动的程序和参数 -->
                           <key>ProgramArguments</key>
                           <array>
                               <!-- 程序路径: 应用程序包内的可执行文件路径 -->
                               <string>${pathProvider.pasteAppPath.resolve("Contents/MacOS/CrossPaste")}</string>
                               <!-- --minimize 参数表示启动时最小化窗口 -->
                               <string>--minimize</string>
                           </array>
                           
                           <!-- RunAtLoad: true 表示在用户登录时自动启动 -->
                           <key>RunAtLoad</key>
                           <true/>
                       </dict>
                       </plist>
                   """.trimIndent().toByteArray())
           }
       } catch (e: Exception) {
           // 记录错误日志,方便排查问题
           logger.error(e) { "Failed to make auto startup" }
       }
   }
   
   override fun isAutoStartUp(): Boolean {
       // 通过检查 plist 文件是否存在来判断是否已配置自启动
       return pathProvider.userHome.resolve("Library/LaunchAgents/$plist").toFile().exists()
   }
   
   override fun removeAutoStartUp() {
       try {
           // 如果已配置自启动,则删除对应的 plist 文件来禁用自启动
           if (isAutoStartUp()) {
               logger.info { "Remove auto startup" }
               pathProvider.userHome.resolve("Library/LaunchAgents/$plist").toFile().delete()
           }
       } catch (e: Exception) {
           logger.error(e) { "Failed to remove auto startup" }
       }
   }
}

2.3 配置说明

plist 文件的关键配置项:

  • Label: 唯一标识符,使用应用的 Bundle ID
  • ProgramArguments: 启动程序路径和参数
  • RunAtLoad: 设置为 true 表示登录时启动

3. Windows 实现

3.1 实现原理

Windows 平台需要区分两种安装方式:

1. 普通安装:通过注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 实现
2. Microsoft Store:由于沙箱权限限制,使用 shell:appsFolder 协议启动

3.2 具体实现

WindowsAppStartUpService

class WindowsAppStartUpService(
   appLaunchState: DesktopAppLaunchState,
   private val configManager: ConfigManager,
) : AppStartUpService {
   companion object {
       // PFN: Package Family Name, Microsoft Store 应用的唯一标识符
       // 格式为: {发布者}.{应用名}_{发布者ID}
       const val PFN = "ShenzhenCompileFutureTech.CrossPaste_gphsk9mrjnczc"
   }
   
   // 判断是否是 Microsoft Store 安装的应用
   private val isMicrosoftStore = appLaunchState.installFrom == MICROSOFT_STORE
   
   // 普通安装版本的可执行文件路径
   private val appExePath = DesktopAppPathProvider.pasteAppPath
       .resolve("bin")
       .resolve("CrossPaste.exe")
   
   // Microsoft Store 应用的启动命令
   // 使用 shell:appsFolder 协议启动 Store 应用,避免权限问题
   // 格式: explorer.exe shell:appsFolder\{PFN}!{AppName}
   private val microsoftStartup = "explorer.exe shell:appsFolder\\$PFN!$AppName"
   
   // 根据安装类型返回对应的启动命令
   private fun getRegValue(): String =
       if (isMicrosoftStore) microsoftStartup else appExePath.toString()
   
   override fun makeAutoStartUp() {
       try {
           if (!isAutoStartUp()) {
               // 通过注册表实现开机自启动
               // 路径: HKCU\Software\Microsoft\Windows\CurrentVersion\Run
               // /v: 指定注册表项名称
               // /d: 指定要执行的命令
               // /f: 强制覆盖已存在的值
               val command = "reg add \"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\" " +
                   "/v \"$AppName\" /d \"${getRegValue()}\" /f"
               Runtime.getRuntime().exec(command)
           }
       } catch (e: Exception) {
           logger.error(e) { "Failed to make auto startup" }
       }
   }
   
   override fun isAutoStartUp(): Boolean {
       // 查询注册表中是否存在对应的自启动项
       val command = "reg query \"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\" /v \"$AppName\""
       try {
           val process = Runtime.getRuntime().exec(command)
           // 读取命令执行结果
           val reader = BufferedReader(InputStreamReader(process.inputStream))
           var line: String?
           while (reader.readLine().also { line = it } != null) {
               // REG_SZ 表示这是一个字符串值
               if (line!!.contains("REG_SZ")) {
                   // 提取注册表项的值并比较
                   // 需要忽略大小写,因为 Windows 路径不区分大小写
                   val registryValue = line.substringAfter("REG_SZ").trim()
                   return registryValue.equals(getRegValue(), ignoreCase = true)
               }
           }
       } catch (e: Exception) {
           logger.error(e) { "Failed to check auto startup status" }
       }
       return false
   }
}

4. Linux 实现

4.1 实现原理

Linux 桌面环境遵循 XDG Autostart 规范:

  • 配置位置:~/.config/autostart/
  • 文件格式:.desktop 文件
  • 兼容性:支持主流桌面环境(GNOME、KDE、XFCE等)

4.2 具体实现

LinuxAppStartUpService

class LinuxAppStartUpService(private val configManager: ConfigManager) : AppStartUpService {
   // 使用 KotlinLogging 创建日志记录器
   private val logger: KLogger = KotlinLogging.logger {}
   
   // desktop 文件名,遵循 Linux 命名规范使用小写
   private val desktopFile = "crosspaste.desktop"
   
   // 应用程序的可执行文件路径
   private val appExePath = pathProvider.pasteAppPath
       .resolve("bin")
       .resolve("crosspaste")
   
   override fun makeAutoStartUp() {
       try {
           if (!isAutoStartUp()) {
               logger.info { "Make auto startup" }
               // 创建 desktop 文件到用户的自启动目录
               // ~/.config/autostart/ 是 XDG 规范定义的用户级自启动目录
               val desktopFilePath = pathProvider.userHome.resolve(".config/autostart/$desktopFile")
               filePersist.createOneFilePersist(desktopFilePath)
                   .saveBytes("""
                       [Desktop Entry]
                       # 声明这是一个应用程序
                       Type=Application
                       # 应用程序显示名称
                       Name=CrossPaste
                       # 启动命令和参数
                       Exec=$appExePath --minimize
                       # 应用程序分类
                       Categories=Utility
                       # 是否需要终端运行
                       Terminal=false
                       
                       # GNOME 桌面环境特定配置
                       # 启用自动启动
                       X-GNOME-Autostart-enabled=true
                       # 登录后延迟 10 秒启动,避免与其他程序冲突
                       X-GNOME-Autostart-Delay=10
                       
                       # KDE 桌面环境特定配置
                       # 指定在面板加载后启动,确保系统托盘可用
                       X-KDE-autostart-after=panel
                   """.trimIndent().toByteArray())
           }
       } catch (e: Exception) {
           logger.error(e) { "Failed to make auto startup" }
       }
   }
   
   override fun isAutoStartUp(): Boolean {
       // 通过检查 desktop 文件是否存在来判断是否已配置自启动
       // Linux 下的 .config/autostart 目录是 XDG 规范定义的用户自启动配置目录
       return pathProvider.userHome.resolve(".config/autostart/$desktopFile").toFile().exists()
   }
}

4.3 配置说明

desktop 文件的关键配置项:

  • Type: 必须为 Application
  • Exec: 启动命令和参数
  • X-GNOME-Autostart-enabled: GNOME 环境的启动控制
  • X-GNOME-Autostart-Delay: 延迟启动时间
  • X-KDE-autostart-after: KDE 环境的启动顺序控制

5. 总结

通过 KMP 实现跨平台的开机自启动,关键在于:

  • 设计统一的接口抽象
  • 理解并正确使用各平台的官方推荐方案
  • 处理好平台特性差异
  • 实现完善的错误处理和日志记录

这种设计既保证了代码的可维护性,又能充分利用各平台的原生特性,为用户提供最佳的使用体验。


GeekCat
819 声望15 粉丝