akanjs 3.0.0-beta.26 → 3.0.0-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/base/baseEnv.ts +19 -5
  2. package/client/csrTypes.ts +2 -0
  3. package/client/device.ts +28 -12
  4. package/client/index.ts +1 -0
  5. package/client/native/accessibility.ts +1 -0
  6. package/client/native/app-state.ts +1 -0
  7. package/client/native/app.ts +1 -0
  8. package/client/native/appearance.ts +1 -0
  9. package/client/native/auth-session.ts +1 -0
  10. package/client/native/autostart.ts +1 -0
  11. package/client/native/badge.ts +1 -0
  12. package/client/native/biometric.ts +1 -0
  13. package/client/native/browser.ts +1 -0
  14. package/client/native/camera.ts +1 -0
  15. package/client/native/clipboard.ts +1 -0
  16. package/client/native/contacts.ts +1 -0
  17. package/client/native/device.ts +1 -0
  18. package/client/native/dialog.ts +1 -0
  19. package/client/native/dock.ts +1 -0
  20. package/client/native/file-picker.ts +1 -0
  21. package/client/native/filesystem.ts +1 -0
  22. package/client/native/geolocation.ts +1 -0
  23. package/client/native/global-shortcut.ts +1 -0
  24. package/client/native/haptics.ts +1 -0
  25. package/client/native/http.ts +1 -0
  26. package/client/native/iap.ts +1 -0
  27. package/client/native/keep-awake.ts +1 -0
  28. package/client/native/keyboard.ts +1 -0
  29. package/client/native/local-notifications.ts +1 -0
  30. package/client/native/menu.ts +1 -0
  31. package/client/native/network.ts +1 -0
  32. package/client/native/opener.ts +1 -0
  33. package/client/native/preferences.ts +1 -0
  34. package/client/native/push.ts +1 -0
  35. package/client/native/screen-orientation.ts +1 -0
  36. package/client/native/screen.ts +1 -0
  37. package/client/native/secure-storage.ts +1 -0
  38. package/client/native/share.ts +1 -0
  39. package/client/native/single-instance.ts +1 -0
  40. package/client/native/splash-screen.ts +1 -0
  41. package/client/native/sqlite.ts +1 -0
  42. package/client/native/toast.ts +1 -0
  43. package/client/native/tray.ts +1 -0
  44. package/client/native/updates.ts +1 -0
  45. package/client/native/volume.ts +1 -0
  46. package/client/native/window-state.ts +1 -0
  47. package/client/native/window.ts +1 -0
  48. package/client/native.ts +31 -1
  49. package/client/resolveServerUrl.ts +11 -0
  50. package/client/router.ts +8 -0
  51. package/common/Logger.ts +26 -2
  52. package/common/RouteLayering.ts +52 -0
  53. package/common/index.ts +1 -0
  54. package/index.ts +115 -6
  55. package/native/desktop.ts +5 -0
  56. package/package.json +11 -1
  57. package/server/akanApp.ts +23 -3
  58. package/server/akanAppHeaders.ts +4 -1
  59. package/server/akanServer.ts +27 -7
  60. package/server/index.ts +1 -0
  61. package/server/lifecycle/orphanGroup.ts +21 -0
  62. package/server/nativeFile.ts +107 -0
  63. package/server/ops/opsRoute.ts +3 -2
  64. package/server/routeTreeBuilder.ts +14 -39
  65. package/server/routing/apiRouter.ts +26 -3
  66. package/server/routing/hostAllowlist.ts +24 -0
  67. package/server/selfExec.ts +25 -0
  68. package/server/types.tsx +3 -1
  69. package/server/webRouter.ts +7 -4
  70. package/service/ipcTypes.ts +4 -0
  71. package/signal/CrossSiteGuard.ts +7 -0
  72. package/test/signalTestRuntime.ts +8 -2
  73. package/types/base/baseEnv.d.ts +1 -0
  74. package/types/client/csrTypes.d.ts +2 -0
  75. package/types/client/index.d.ts +1 -0
  76. package/types/client/native/accessibility.d.ts +1 -0
  77. package/types/client/native/app-state.d.ts +1 -0
  78. package/types/client/native/app.d.ts +1 -0
  79. package/types/client/native/appearance.d.ts +1 -0
  80. package/types/client/native/auth-session.d.ts +1 -0
  81. package/types/client/native/autostart.d.ts +1 -0
  82. package/types/client/native/badge.d.ts +1 -0
  83. package/types/client/native/biometric.d.ts +1 -0
  84. package/types/client/native/browser.d.ts +1 -0
  85. package/types/client/native/camera.d.ts +1 -0
  86. package/types/client/native/clipboard.d.ts +1 -0
  87. package/types/client/native/contacts.d.ts +1 -0
  88. package/types/client/native/device.d.ts +1 -0
  89. package/types/client/native/dialog.d.ts +1 -0
  90. package/types/client/native/dock.d.ts +1 -0
  91. package/types/client/native/file-picker.d.ts +1 -0
  92. package/types/client/native/filesystem.d.ts +1 -0
  93. package/types/client/native/geolocation.d.ts +1 -0
  94. package/types/client/native/global-shortcut.d.ts +1 -0
  95. package/types/client/native/haptics.d.ts +1 -0
  96. package/types/client/native/http.d.ts +1 -0
  97. package/types/client/native/iap.d.ts +1 -0
  98. package/types/client/native/keep-awake.d.ts +1 -0
  99. package/types/client/native/keyboard.d.ts +1 -0
  100. package/types/client/native/local-notifications.d.ts +1 -0
  101. package/types/client/native/menu.d.ts +1 -0
  102. package/types/client/native/network.d.ts +1 -0
  103. package/types/client/native/opener.d.ts +1 -0
  104. package/types/client/native/preferences.d.ts +1 -0
  105. package/types/client/native/push.d.ts +1 -0
  106. package/types/client/native/screen-orientation.d.ts +1 -0
  107. package/types/client/native/screen.d.ts +1 -0
  108. package/types/client/native/secure-storage.d.ts +1 -0
  109. package/types/client/native/share.d.ts +1 -0
  110. package/types/client/native/single-instance.d.ts +1 -0
  111. package/types/client/native/splash-screen.d.ts +1 -0
  112. package/types/client/native/sqlite.d.ts +1 -0
  113. package/types/client/native/toast.d.ts +1 -0
  114. package/types/client/native/tray.d.ts +1 -0
  115. package/types/client/native/updates.d.ts +1 -0
  116. package/types/client/native/volume.d.ts +1 -0
  117. package/types/client/native/window-state.d.ts +1 -0
  118. package/types/client/native/window.d.ts +1 -0
  119. package/types/client/native.d.ts +14 -2
  120. package/types/client/resolveServerUrl.d.ts +1 -0
  121. package/types/client/router.d.ts +3 -0
  122. package/types/common/RouteLayering.d.ts +28 -0
  123. package/types/common/index.d.ts +1 -0
  124. package/types/index.d.ts +112 -6
  125. package/types/native/desktop.d.ts +3 -0
  126. package/types/server/akanAppHeaders.d.ts +2 -0
  127. package/types/server/index.d.ts +1 -0
  128. package/types/server/lifecycle/orphanGroup.d.ts +7 -0
  129. package/types/server/nativeFile.d.ts +10 -0
  130. package/types/server/routing/apiRouter.d.ts +3 -1
  131. package/types/server/routing/hostAllowlist.d.ts +8 -0
  132. package/types/server/selfExec.d.ts +8 -0
  133. package/types/server/types.d.ts +3 -1
  134. package/types/service/ipcTypes.d.ts +16 -0
  135. package/types/signal/CrossSiteGuard.d.ts +1 -0
  136. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  137. package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +57 -4
  138. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +48 -0
  139. package/types/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.d.ts +8 -0
  140. package/types/vendor/@akanjs/native/packages/cli/src/lib/prepare.d.ts +8 -2
  141. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +10 -0
  142. package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +24 -0
  143. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +24 -0
  144. package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +4 -1
  145. package/types/vendor/@akanjs/native/packages/cli/src/platforms/macos.d.ts +5 -0
  146. package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +38 -0
  147. package/types/vendor/@akanjs/native/packages/desktop/src/dispatcher.d.ts +8 -1
  148. package/types/vendor/@akanjs/native/packages/desktop/src/grants.d.ts +11 -0
  149. package/types/vendor/@akanjs/native/packages/desktop/src/main.d.ts +11 -0
  150. package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +13 -2
  151. package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +27 -0
  152. package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +21 -0
  153. package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +79 -0
  154. package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +5 -0
  155. package/types/vendor/@akanjs/native/plugins/file-picker/src/index.d.ts +20 -1
  156. package/types/vendor/@akanjs/native/plugins/push/src/index.d.ts +9 -0
  157. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +28 -0
  158. package/types/vendor/@akanjs/native/plugins/volume/src/common.d.ts +10 -0
  159. package/types/vendor/@akanjs/native/plugins/volume/src/desktop.d.ts +18 -0
  160. package/types/vendor/@akanjs/native/plugins/volume/src/index.d.ts +28 -0
  161. package/types/vendor/@akanjs/native/plugins/volume/src.d.ts +1 -0
  162. package/types/webkit/csrFrameDump.d.ts +42 -0
  163. package/types/webkit/nativeNavigation.d.ts +1 -0
  164. package/types/webkit/nativePurchase.d.ts +6 -2
  165. package/types/webkit/nativeUpdates.d.ts +8 -0
  166. package/ui/Agent/Attach.tsx +6 -2
  167. package/ui/CsrImage.tsx +3 -2
  168. package/ui/System/CSR.tsx +4 -0
  169. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +5 -1
  170. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeBridge.kt +19 -0
  171. package/vendor/@akanjs/native/native/desktop/Cargo.toml +1 -1
  172. package/vendor/@akanjs/native/native/desktop/src/lib.rs +161 -25
  173. package/vendor/@akanjs/native/native/desktop/src/volume.rs +184 -0
  174. package/vendor/@akanjs/native/native/desktop/src/win/mod.rs +3 -1
  175. package/vendor/@akanjs/native/native/desktop/src/win/volume.rs +71 -0
  176. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeShell.swift +12 -2
  177. package/vendor/@akanjs/native/packages/cli/src/api.ts +142 -12
  178. package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +5 -3
  179. package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +10 -71
  180. package/vendor/@akanjs/native/packages/cli/src/config.ts +41 -0
  181. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -2
  182. package/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.ts +58 -0
  183. package/vendor/@akanjs/native/packages/cli/src/lib/hmr.ts +2 -0
  184. package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +10 -2
  185. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +38 -2
  186. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +104 -0
  187. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +85 -5
  188. package/vendor/@akanjs/native/packages/cli/src/platforms/android.ts +1 -0
  189. package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +6 -4
  190. package/vendor/@akanjs/native/packages/cli/src/platforms/desktop.ts +16 -0
  191. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +44 -1
  192. package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +436 -0
  193. package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +2 -0
  194. package/vendor/@akanjs/native/packages/core/src/runtime.ts +26 -5
  195. package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +20 -0
  196. package/vendor/@akanjs/native/packages/desktop/src/grants.ts +47 -0
  197. package/vendor/@akanjs/native/packages/desktop/src/host.ts +102 -14
  198. package/vendor/@akanjs/native/packages/desktop/src/main.ts +59 -11
  199. package/vendor/@akanjs/native/packages/desktop/src/paths.ts +38 -4
  200. package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +28 -0
  201. package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +159 -0
  202. package/vendor/@akanjs/native/packages/desktop/src/server.ts +493 -0
  203. package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +14 -0
  204. package/vendor/@akanjs/native/plugins/app/native-plugin.json +2 -2
  205. package/vendor/@akanjs/native/plugins/app/src/desktop.ts +5 -0
  206. package/vendor/@akanjs/native/plugins/app/src/index.ts +6 -1
  207. package/vendor/@akanjs/native/plugins/app/src/web.ts +3 -0
  208. package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +39 -2
  209. package/vendor/@akanjs/native/plugins/file-picker/src/index.ts +36 -1
  210. package/vendor/@akanjs/native/plugins/push/android/PushPlugin.kt +9 -1
  211. package/vendor/@akanjs/native/plugins/push/ios/PushPlugin.swift +4 -0
  212. package/vendor/@akanjs/native/plugins/push/src/index.ts +10 -0
  213. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +464 -452
  214. package/vendor/@akanjs/native/plugins/volume/android/VolumePlugin.kt +107 -0
  215. package/vendor/@akanjs/native/plugins/volume/native-plugin.json +14 -0
  216. package/vendor/@akanjs/native/plugins/volume/src/common.ts +28 -0
  217. package/vendor/@akanjs/native/plugins/volume/src/desktop.ts +185 -0
  218. package/vendor/@akanjs/native/plugins/volume/src/index.ts +32 -0
  219. package/webkit/CsrRouteTable.ts +15 -35
  220. package/webkit/RenderLayer.tsx +71 -6
  221. package/webkit/bootCsr.tsx +11 -8
  222. package/webkit/csrFrameDump.ts +67 -0
  223. package/webkit/nativeNavigation.ts +17 -0
  224. package/webkit/nativePurchase.ts +12 -2
  225. package/webkit/nativeUpdates.ts +55 -0
  226. package/webkit/useCsrValues.ts +27 -0
@@ -115,6 +115,8 @@ class AkanNativeActivity : Activity(), AkanNativePluginContext.Host {
115
115
  setSupportMultipleWindows(false)
116
116
  javaScriptCanOpenWindowsAutomatically = false
117
117
  setSupportZoom(false)
118
+ // android.autoplay: media plays without a tap first, as it does on iOS and the desktop.
119
+ mediaPlaybackRequiresUserGesture = !shell.optBoolean("autoplay")
118
120
  }
119
121
  // The page follows prefers-color-scheme itself: no darkening by WebView.
120
122
  if (Build.VERSION.SDK_INT >= 33) Api33.noAlgorithmicDarkening(webView.settings)
@@ -396,13 +398,15 @@ class AkanNativeActivity : Activity(), AkanNativePluginContext.Host {
396
398
 
397
399
  private inner class ChromeClient : WebChromeClient() {
398
400
  override fun onConsoleMessage(message: ConsoleMessage): Boolean { // WV-3
401
+ // A dev page forwards its console through the bridge once its runtime is up; this copy covers the rest.
402
+ if (bridge.consoleForwarded) return true
399
403
  val priority = when (message.messageLevel()) {
400
404
  ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
401
405
  ConsoleMessage.MessageLevel.WARNING -> Log.WARN
402
406
  ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
403
407
  else -> Log.INFO
404
408
  }
405
- Log.println(priority, "AkanNativeConsole", message.message())
409
+ Log.println(priority, "AkanNativeConsole", message.message().trimEnd('\n'))
406
410
  return true
407
411
  }
408
412
 
@@ -53,6 +53,8 @@ class AkanNativeBridge(private val webView: WebView) {
53
53
 
54
54
  /** Dev builds: the self-test's \$host methods. */
55
55
  var dev = false
56
+ /** The page's runtime forwards its console here (a dev build's `$console` attach), so the WebView's copy is spare. */
57
+ @Volatile var consoleForwarded = false
56
58
 
57
59
  /** Dev builds: \$host.hang calls still open, and how many were cancelled. */
58
60
  private val hanging = ArrayList<AkanNativeCall>()
@@ -207,6 +209,7 @@ class AkanNativeBridge(private val webView: WebView) {
207
209
  if (pluginId == "\$bridge") return bridgeOp(call, doc)
208
210
  doc.running[id] = java.lang.ref.WeakReference(call)
209
211
  call.document = doc
212
+ if (pluginId == "\$console") return console(call)
210
213
  if (pluginId == "\$host") return host(call)
211
214
  val plugin = plugins[pluginId] ?: return call.reject(AkanNativeErrorCode.NOT_FOUND, "plugin $pluginId is not registered")
212
215
  // L2: an undeclared method or event never reaches the plugin, whatever its code handles.
@@ -333,6 +336,22 @@ class AkanNativeBridge(private val webView: WebView) {
333
336
  JSONObject().put("v", 1).put("id", id).put("ok", false).put("error", JSONObject().put("code", code).put("message", message))
334
337
 
335
338
  /** Shell built-ins the page runtime calls. Dev builds only: the self-test's bridge checks. */
339
+ /** dev builds: the page console at its own priority (WV-3), through the bridge as on iOS and the desktop. */
340
+ private fun console(call: AkanNativeCall) {
341
+ if (!dev) return call.reject(AkanNativeErrorCode.NOT_FOUND, "plugin \$console is not registered")
342
+ consoleForwarded = true
343
+ if (call.method != "attach") {
344
+ val priority = when (call.method) {
345
+ "error" -> Log.ERROR
346
+ "warn" -> Log.WARN
347
+ "trace", "verbose", "debug" -> Log.DEBUG
348
+ else -> Log.INFO
349
+ }
350
+ Log.println(priority, "AkanNativeConsole", (call.string("message") ?: "").trimEnd('\n'))
351
+ }
352
+ call.resolve()
353
+ }
354
+
336
355
  private fun host(call: AkanNativeCall) {
337
356
  // AkanNativeFeatures.DEV is a constant: release builds (false) lose these methods and what only they
338
357
  // reach (AkanNativeVectors) in R8 (architecture review stage 6).
@@ -27,7 +27,7 @@ block2 = "0.6"
27
27
 
28
28
  [target.'cfg(target_os = "windows")'.dependencies]
29
29
  # windows-rs (Microsoft's Win32 bindings), already in the wry/tao tree; we only enable features.
30
- windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_System_Com", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Power", "Win32_System_Registry", "Win32_System_Threading", "Data_Xml_Dom", "Foundation", "UI_Notifications", "Win32_Security_Credentials", "Win32_System_Time", "Win32_UI_Controls", "Win32_UI_Shell_Common", "Win32_Graphics_Gdi", "Win32_System_ApplicationInstallationAndServicing", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi"] }
30
+ windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_System_Com", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Power", "Win32_System_Registry", "Win32_System_Threading", "Data_Xml_Dom", "Foundation", "UI_Notifications", "Win32_Security_Credentials", "Win32_System_Time", "Win32_UI_Controls", "Win32_UI_Shell_Common", "Win32_Graphics_Gdi", "Win32_System_ApplicationInstallationAndServicing", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com_StructuredStorage", "Win32_System_Variant"] }
31
31
  # WebView2's COM API, the version wry uses: ScriptDialogOpening (D7 under tests, win/dialogs.rs).
32
32
  webview2-com = "0.39"
33
33
 
@@ -48,6 +48,8 @@ mod dock;
48
48
  #[cfg(target_os = "macos")]
49
49
  mod screen;
50
50
  #[cfg(target_os = "macos")]
51
+ mod volume;
52
+ #[cfg(target_os = "macos")]
51
53
  mod autostart;
52
54
  #[cfg(target_os = "macos")]
53
55
  mod keychain;
@@ -379,6 +381,8 @@ enum UserEvent {
379
381
  ThemeChanged,
380
382
  /// A window's web content process ended (crashed, killed, out of memory): its document is gone (N2).
381
383
  WebviewGone { window: u32, reason: &'static str },
384
+ /// desktop.recovery "reload": the wait after a window's page ended in a row is over; load it again.
385
+ ReloadGone(u32),
382
386
  }
383
387
 
384
388
  // ───────────────────────── C ABI ─────────────────────────
@@ -759,6 +763,15 @@ struct Config {
759
763
  app_id: String,
760
764
  /// security.shell.externalSchemes (L0): schemes links may also hand to the OS (navigation.rs).
761
765
  external_schemes: Vec<String>,
766
+ /// desktop.recovery "reload": a page that ends is always loaded again, and a browser process that ends
767
+ /// is the host's to relaunch (host.ts), instead of an error page and a quit.
768
+ recovery_reload: bool,
769
+ /// The main window from its first frame (desktop.window, or the launch phase): borderless fullscreen on
770
+ /// the display it is placed on, and no taskbar button (Windows, Linux).
771
+ fullscreen: bool,
772
+ skip_taskbar: bool,
773
+ /// desktop.screenCapture "auto" (Windows): the page captures the first screen without the picker.
774
+ screen_capture_auto: bool,
762
775
  }
763
776
 
764
777
  #[derive(Clone, Copy, PartialEq)]
@@ -821,6 +834,18 @@ fn parse_config(s: &str) -> Result<Config, String> {
821
834
  Some(json::V::Arr(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
822
835
  _ => Vec::new(),
823
836
  },
837
+ recovery_reload: match get_s("recovery").as_deref() {
838
+ None | Some("errorPage") => false,
839
+ Some("reload") => true,
840
+ Some(other) => return Err(format!("recovery must be \"errorPage\" or \"reload\", not {other:?}")),
841
+ },
842
+ fullscreen: get_b("fullscreen", false),
843
+ skip_taskbar: get_b("skipTaskbar", false),
844
+ screen_capture_auto: match get_s("screenCapture").as_deref() {
845
+ None | Some("picker") => false,
846
+ Some("auto") => true,
847
+ Some(other) => return Err(format!("screenCapture must be \"picker\" or \"auto\", not {other:?}")),
848
+ },
824
849
  })
825
850
  }
826
851
 
@@ -1084,6 +1109,9 @@ struct Shell {
1084
1109
  icon: Option<tao::window::Icon>,
1085
1110
  #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1086
1111
  app_id: String,
1112
+ recovery_reload: bool,
1113
+ #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1114
+ screen_capture_auto: bool,
1087
1115
  }
1088
1116
 
1089
1117
  /// The CLI's icon.rgba (icons.ts windowIcon) as a tao icon.
@@ -1101,15 +1129,25 @@ struct WindowOptions {
1101
1129
  size: Option<(f64, f64)>,
1102
1130
  position: Option<(f64, f64)>,
1103
1131
  maximized: bool,
1132
+ fullscreen: bool,
1133
+ /// macOS has no taskbar: its Dock shows apps, not windows.
1134
+ #[cfg_attr(target_os = "macos", allow(dead_code))]
1135
+ skip_taskbar: bool,
1104
1136
  }
1105
1137
 
1106
1138
  /// One window. The webview is declared first so that it is dropped before its window.
1107
1139
  struct Win {
1108
1140
  webview: WebView,
1109
1141
  window: tao::window::Window,
1110
- /// When its web content process last ended: a second end within a minute shows an error page
1111
- /// instead of reloading again (a page that crashes on load would loop).
1112
- gone_at: Option<std::time::Instant>,
1142
+ /// When its page was last loaded again after its web content process ended: an end within a minute
1143
+ /// of that shows an error page (a page that crashes on load would loop), or with desktop.recovery
1144
+ /// "reload" waits longer before the next load (recovery_wait).
1145
+ reloaded_at: Option<std::time::Instant>,
1146
+ /// Ends in a row, each within a minute of the load before (desktop.recovery "reload").
1147
+ gone_streak: u32,
1148
+ /// A reload is waiting out recovery_wait: ends reported meanwhile (an unresponsive page repeats its report) are
1149
+ /// the same end, and must neither raise the streak nor start a second wait.
1150
+ reload_pending: bool,
1113
1151
  }
1114
1152
 
1115
1153
  struct Windows {
@@ -1133,14 +1171,27 @@ fn is_dark() -> bool {
1133
1171
  }
1134
1172
 
1135
1173
  fn monitors(target: &EventLoopWindowTarget<UserEvent>) -> Vec<placement::Rect> {
1136
- target
1137
- .available_monitors()
1138
- .map(|m| {
1139
- let scale = m.scale_factor();
1140
- let (p, s) = (m.position().to_logical::<f64>(scale), m.size().to_logical::<f64>(scale));
1141
- placement::Rect { x: p.x, y: p.y, width: s.width, height: s.height }
1142
- })
1143
- .collect()
1174
+ target.available_monitors().map(|m| logical_rect(&m)).collect()
1175
+ }
1176
+
1177
+ fn logical_rect(m: &tao::monitor::MonitorHandle) -> placement::Rect {
1178
+ let scale = m.scale_factor();
1179
+ let (p, s) = (m.position().to_logical::<f64>(scale), m.size().to_logical::<f64>(scale));
1180
+ placement::Rect { x: p.x, y: p.y, width: s.width, height: s.height }
1181
+ }
1182
+
1183
+ /// The display holding a logical point.
1184
+ fn monitor_at(target: &EventLoopWindowTarget<UserEvent>, x: f64, y: f64) -> Option<tao::monitor::MonitorHandle> {
1185
+ target.available_monitors().find(|m| {
1186
+ let r = logical_rect(m);
1187
+ x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
1188
+ })
1189
+ }
1190
+
1191
+ /// desktop.recovery "reload": how long to wait before loading a page again after `streak` ends in a
1192
+ /// row. The first end reloads at once; then 1 s, doubling to a minute.
1193
+ fn recovery_wait(streak: u32) -> Option<std::time::Duration> {
1194
+ (streak > 0).then(|| std::time::Duration::from_secs((1u64 << (streak - 1).min(6)).min(60)))
1144
1195
  }
1145
1196
 
1146
1197
  /// Drops the webview's pending bridge calls (main thread): its document is gone.
@@ -1273,16 +1324,33 @@ fn open_window(target: &EventLoopWindowTarget<UserEvent>, shell: &Shell, context
1273
1324
  // (tao/src/platform_impl/macos/window.rs:202-208), so the title bar would end up above it;
1274
1325
  // window.getState reports the outer position, and set_outer_position takes one.
1275
1326
  let mut outer_position = None;
1327
+ let mut placed = None;
1276
1328
  if let Some((x, y)) = opts.position {
1277
1329
  let wanted = placement::Rect { x, y, width, height };
1278
1330
  match placement::place(wanted, &monitors(target), MIN_INNER) {
1279
1331
  Some(r) => {
1280
1332
  builder = builder.with_inner_size(LogicalSize::new(r.width, r.height));
1281
1333
  outer_position = Some(tao::dpi::LogicalPosition::new(r.x, r.y));
1334
+ placed = Some(r);
1282
1335
  }
1283
1336
  None => log!("window {id}: position ({x}, {y}) is on no display; using the default"),
1284
1337
  }
1285
1338
  }
1339
+ if opts.fullscreen {
1340
+ // Set before the first frame, on the display the window was placed on (else the primary one).
1341
+ let monitor = placed.and_then(|r| monitor_at(target, r.x + r.width / 2.0, r.y + r.height / 2.0));
1342
+ builder = builder.with_fullscreen(Some(tao::window::Fullscreen::Borderless(monitor)));
1343
+ }
1344
+ #[cfg(target_os = "windows")]
1345
+ if opts.skip_taskbar {
1346
+ use tao::platform::windows::WindowBuilderExtWindows;
1347
+ builder = builder.with_skip_taskbar(true);
1348
+ }
1349
+ #[cfg(target_os = "linux")]
1350
+ if opts.skip_taskbar {
1351
+ use tao::platform::unix::WindowBuilderExtUnix;
1352
+ builder = builder.with_skip_taskbar(true);
1353
+ }
1286
1354
  let window = builder.build(target).map_err(|e| format!("window: {e}"))?;
1287
1355
  // Windows and Linux: the system theme is known once a window exists (tao reads it then).
1288
1356
  #[cfg(not(target_os = "macos"))]
@@ -1327,11 +1395,18 @@ fn open_window(target: &EventLoopWindowTarget<UserEvent>, shell: &Shell, context
1327
1395
  // Dev builds: AKAN_NATIVE_WEBVIEW2_DEBUG_PORT opens the Chrome DevTools Protocol on that port, for
1328
1396
  // test automation. WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS does not apply: wry passes arguments of
1329
1397
  // its own, repeated here because these replace them (wry webview2/mod.rs, with autoplay on).
1330
- match std::env::var("AKAN_NATIVE_WEBVIEW2_DEBUG_PORT").ok().filter(|_| shell.devtools).and_then(|p| p.parse::<u16>().ok()) {
1331
- Some(port) => builder.with_additional_browser_args(format!(
1332
- "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --remote-debugging-port={port}"
1333
- )),
1334
- None => builder,
1398
+ let port = std::env::var("AKAN_NATIVE_WEBVIEW2_DEBUG_PORT").ok().filter(|_| shell.devtools).and_then(|p| p.parse::<u16>().ok());
1399
+ // desktop.screenCapture "auto": getDisplayMedia answers with the first screen, no picker, no user gesture. Not
1400
+ // --auto-select-desktop-capture-source: it matches the picker's title, which follows the UI language (verified).
1401
+ let capture = shell.screen_capture_auto.then(|| "--use-fake-ui-for-media-stream".to_string());
1402
+ let extra: Vec<String> = port.map(|p| format!("--remote-debugging-port={p}")).into_iter().chain(capture).collect();
1403
+ if extra.is_empty() {
1404
+ builder
1405
+ } else {
1406
+ builder.with_additional_browser_args(format!(
1407
+ "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required {}",
1408
+ extra.join(" ")
1409
+ ))
1335
1410
  }
1336
1411
  };
1337
1412
  // SH-4: pages never leave the app origin (navigation.rs). Linux decides in its own signal handlers.
@@ -1435,7 +1510,7 @@ fn open_window(target: &EventLoopWindowTarget<UserEvent>, shell: &Shell, context
1435
1510
  std::thread::sleep(Duration::from_millis(3000));
1436
1511
  send(UserEvent::Show(id));
1437
1512
  });
1438
- Ok(Win { webview, window, gone_at: None })
1513
+ Ok(Win { webview, window, reloaded_at: None, gone_streak: 0, reload_pending: false })
1439
1514
  }
1440
1515
 
1441
1516
  /// A `window` argument: a positive whole number.
@@ -1524,7 +1599,7 @@ impl Windows {
1524
1599
  };
1525
1600
  let id = self.next;
1526
1601
  self.next += 1;
1527
- let win = open_window(target, &self.shell, &mut self.web_context, id, WindowOptions { path: app_path(cmd)?, title, size, position, maximized: false })?;
1602
+ let win = open_window(target, &self.shell, &mut self.web_context, id, WindowOptions { path: app_path(cmd)?, title, size, position, maximized: false, fullscreen: false, skip_taskbar: false })?;
1528
1603
  let state = self.state(id, &win);
1529
1604
  self.insert(id, win);
1530
1605
  push_event(&format!(r#"{{"type":"window","event":"created","window":{id}}}"#));
@@ -1573,14 +1648,15 @@ impl Windows {
1573
1648
  "debug.stats" if self.shell.devtools => return Ok(stats_json()),
1574
1649
  _ => {}
1575
1650
  }
1576
- // App-wide ops, not a window's: menu, tray, global shortcut, dock and screen plugins
1577
- // (menu.rs, tray.rs, hotkey.rs, dock.rs, screen.rs).
1651
+ // App-wide ops, not a window's: menu, tray, global shortcut, dock, screen and volume plugins
1652
+ // (menu.rs, tray.rs, hotkey.rs, dock.rs, screen.rs, volume.rs).
1578
1653
  #[cfg(target_os = "macos")]
1579
1654
  if let Some(result) = menu::shell_op(op, &cmd)
1580
1655
  .or_else(|| tray::shell_op(op, &cmd, &self.shell.app_dir))
1581
1656
  .or_else(|| hotkey::shell_op(op, &cmd))
1582
1657
  .or_else(|| dock::shell_op(op, &cmd, target))
1583
1658
  .or_else(|| screen::shell_op(op, target))
1659
+ .or_else(|| volume::shell_op(op, &cmd))
1584
1660
  {
1585
1661
  return result;
1586
1662
  }
@@ -1775,6 +1851,8 @@ pub unsafe extern "C" fn akan_native_run(config_json: *const c_char) -> i32 {
1775
1851
  activate: cfg.activation != "prohibited",
1776
1852
  icon: cfg.icon.as_deref().and_then(read_icon),
1777
1853
  app_id: cfg.app_id.clone(),
1854
+ recovery_reload: cfg.recovery_reload,
1855
+ screen_capture_auto: cfg.screen_capture_auto,
1778
1856
  },
1779
1857
  web_context: wry::WebContext::new(cfg.data_dir.clone()),
1780
1858
  map: HashMap::new(),
@@ -1783,7 +1861,15 @@ pub unsafe extern "C" fn akan_native_run(config_json: *const c_char) -> i32 {
1783
1861
  focus: Vec::new(),
1784
1862
  next: 2,
1785
1863
  };
1786
- let main = WindowOptions { path: cfg.start_path.clone(), title: None, size: None, position: cfg.position, maximized: cfg.maximized };
1864
+ let main = WindowOptions {
1865
+ path: cfg.start_path.clone(),
1866
+ title: None,
1867
+ size: None,
1868
+ position: cfg.position,
1869
+ maximized: cfg.maximized,
1870
+ fullscreen: cfg.fullscreen,
1871
+ skip_taskbar: cfg.skip_taskbar,
1872
+ };
1787
1873
  match open_window(&event_loop, &windows.shell, &mut windows.web_context, 1, main) {
1788
1874
  Ok(win) => windows.insert(1, win),
1789
1875
  Err(e) => {
@@ -1884,22 +1970,49 @@ pub unsafe extern "C" fn akan_native_run(config_json: *const c_char) -> i32 {
1884
1970
  // The page and every call it had made are gone: the host ends its document (dispatcher.reset).
1885
1971
  drop_pending(window);
1886
1972
  push_event(&format!(r#"{{"type":"webview","event":"processTerminated","window":{window},"reason":"{reason}"}}"#));
1887
- if reason == "browserExited" {
1973
+ if reason == "browserExited" && windows.shell.recovery_reload {
1974
+ // The host relaunches the app on the event above (host.ts); should it not, this quits.
1975
+ log!("the webview's browser process ended; the app relaunches");
1976
+ std::thread::spawn(|| {
1977
+ std::thread::sleep(std::time::Duration::from_secs(10));
1978
+ send(UserEvent::Quit(1));
1979
+ });
1980
+ } else if reason == "browserExited" {
1888
1981
  log!("the webview's browser process ended; quitting");
1889
1982
  send(UserEvent::Quit(1));
1890
1983
  } else if let Some(win) = windows.map.get_mut(&window) {
1984
+ if win.reload_pending {
1985
+ log!("window {window}: the page stopped ({reason}) while its reload waits");
1986
+ return;
1987
+ }
1891
1988
  let now = std::time::Instant::now();
1892
- let again = win.gone_at.is_some_and(|t| now.duration_since(t) < std::time::Duration::from_secs(60));
1893
- win.gone_at = Some(now);
1894
- if again {
1989
+ let again = win.reloaded_at.is_some_and(|t| now.duration_since(t) < std::time::Duration::from_secs(60));
1990
+ win.gone_streak = if again { win.gone_streak + 1 } else { 0 };
1991
+ let wait = recovery_wait(win.gone_streak).filter(|_| windows.shell.recovery_reload);
1992
+ if again && !windows.shell.recovery_reload {
1895
1993
  log!("window {window}: the page stopped again ({reason}) within a minute; showing an error page");
1896
1994
  let _ = win.webview.load_html(&gone_page());
1995
+ } else if let Some(wait) = wait {
1996
+ log!("window {window}: the page stopped again ({reason}); loading it again in {} s", wait.as_secs());
1997
+ win.reload_pending = true;
1998
+ std::thread::spawn(move || {
1999
+ std::thread::sleep(wait);
2000
+ send(UserEvent::ReloadGone(window));
2001
+ });
1897
2002
  } else {
1898
2003
  log!("window {window}: the page stopped ({reason}); loading it again");
2004
+ win.reloaded_at = Some(now);
1899
2005
  let _ = win.webview.reload();
1900
2006
  }
1901
2007
  }
1902
2008
  }
2009
+ Event::UserEvent(UserEvent::ReloadGone(window)) => {
2010
+ if let Some(win) = windows.map.get_mut(&window) {
2011
+ win.reload_pending = false;
2012
+ win.reloaded_at = Some(std::time::Instant::now());
2013
+ let _ = win.webview.reload();
2014
+ }
2015
+ }
1903
2016
  Event::UserEvent(UserEvent::Emit { webview_id, js }) => {
1904
2017
  if let Some(win) = windows.map.get(&webview_id) {
1905
2018
  let _ = win.webview.evaluate_script(&js);
@@ -2183,3 +2296,26 @@ mod drag_drop_tests {
2183
2296
  assert!(enter.ends_with(r#""x":100,"y":200.67}"#), "{enter}");
2184
2297
  }
2185
2298
  }
2299
+
2300
+ #[cfg(test)]
2301
+ mod recovery_tests {
2302
+ use super::recovery_wait;
2303
+ use std::time::Duration;
2304
+
2305
+ #[test]
2306
+ fn waits_longer_after_each_end_in_a_row_up_to_a_minute() {
2307
+ let secs: Vec<u64> = (0..10).map(|streak| recovery_wait(streak).map_or(0, |d| d.as_secs())).collect();
2308
+ assert_eq!(secs, [0, 1, 2, 4, 8, 16, 32, 60, 60, 60]);
2309
+ assert_eq!(recovery_wait(u32::MAX), Some(Duration::from_secs(60)));
2310
+ }
2311
+
2312
+ #[test]
2313
+ fn reads_the_recovery_policy_and_refuses_an_unknown_one() {
2314
+ let base = r#""appDir":"/a","initJs":"x""#;
2315
+ assert!(!super::parse_config(&format!("{{{base}}}")).unwrap().recovery_reload);
2316
+ assert!(super::parse_config(&format!(r#"{{{base},"recovery":"reload","fullscreen":true}}"#)).unwrap().recovery_reload);
2317
+ assert!(super::parse_config(&format!(r#"{{{base},"recovery":"always"}}"#)).is_err());
2318
+ assert!(super::parse_config(&format!(r#"{{{base},"screenCapture":"auto"}}"#)).unwrap().screen_capture_auto);
2319
+ assert!(super::parse_config(&format!(r#"{{{base},"screenCapture":"any"}}"#)).is_err());
2320
+ }
2321
+ }
@@ -0,0 +1,184 @@
1
+ //! The system output volume on macOS (volume plugin): the default output device's volume and mute,
2
+ //! through CoreAudio's AudioObject API. The few C calls are declared here; the framework ships with macOS.
3
+ //! volume.get → {"level":0…1|null,"muted":bool,"settable":bool}
4
+ //! volume.set {level} → the same, after the change
5
+ //! volume.mute {muted} → the same
6
+ //! volume.watch → null. From then on the shell sends {"type":"volume","event":"changed"} when the
7
+ //! default output device changes, or its volume or mute does; the plugin host reads
8
+ //! volume.get again.
9
+ //!
10
+ //! - The level is the device's virtual main volume ('vmvc'), which CoreAudio spreads over the channels
11
+ //! as the volume keys do. A device without one (HDMI or DisplayPort audio, many USB interfaces)
12
+ //! answers level null and settable false, and volume.set refuses: the volume keys do nothing there.
13
+ //! - Listeners run on a CoreAudio thread; push_event is thread-safe. A device that stops being the
14
+ //! default keeps its listeners, and the host drops the events that change nothing.
15
+
16
+ use std::{collections::HashSet, ffi::c_void, ptr, sync::Mutex};
17
+
18
+ use crate::{json, push_event};
19
+
20
+ #[repr(C)]
21
+ struct Address {
22
+ selector: u32,
23
+ scope: u32,
24
+ element: u32,
25
+ }
26
+
27
+ type Listener = extern "C" fn(u32, u32, *const Address, *mut c_void) -> i32;
28
+
29
+ #[link(name = "CoreAudio", kind = "framework")]
30
+ extern "C" {
31
+ fn AudioObjectGetPropertyData(id: u32, address: *const Address, qualifier_size: u32, qualifier: *const c_void, size: *mut u32, data: *mut c_void) -> i32;
32
+ fn AudioObjectSetPropertyData(id: u32, address: *const Address, qualifier_size: u32, qualifier: *const c_void, size: u32, data: *const c_void) -> i32;
33
+ fn AudioObjectHasProperty(id: u32, address: *const Address) -> u8;
34
+ fn AudioObjectIsPropertySettable(id: u32, address: *const Address, settable: *mut u8) -> i32;
35
+ fn AudioObjectAddPropertyListener(id: u32, address: *const Address, listener: Listener, client: *mut c_void) -> i32;
36
+ }
37
+
38
+ const fn code(s: &[u8; 4]) -> u32 {
39
+ u32::from_be_bytes(*s)
40
+ }
41
+
42
+ /// kAudioObjectSystemObject
43
+ const SYSTEM: u32 = 1;
44
+ /// kAudioHardwarePropertyDefaultOutputDevice
45
+ const DEFAULT_OUTPUT: Address = Address { selector: code(b"dOut"), scope: code(b"glob"), element: 0 };
46
+ /// kAudioHardwareServiceDeviceProperty_VirtualMainVolume on the output scope's main element
47
+ const VOLUME: Address = Address { selector: code(b"vmvc"), scope: code(b"outp"), element: 0 };
48
+ /// kAudioDevicePropertyMute
49
+ const MUTE: Address = Address { selector: code(b"mute"), scope: code(b"outp"), element: 0 };
50
+
51
+ static WATCHED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
52
+
53
+ fn default_output() -> Result<u32, String> {
54
+ let mut id = 0u32;
55
+ let mut size = size_of::<u32>() as u32;
56
+ let status = unsafe { AudioObjectGetPropertyData(SYSTEM, &DEFAULT_OUTPUT, 0, ptr::null(), &mut size, &mut id as *mut u32 as *mut c_void) };
57
+ if status != 0 || id == 0 {
58
+ return Err("NOT_FOUND: there is no audio output".into());
59
+ }
60
+ Ok(id)
61
+ }
62
+
63
+ fn read<T: Default>(device: u32, address: &Address) -> Option<T> {
64
+ if unsafe { AudioObjectHasProperty(device, address) } == 0 {
65
+ return None;
66
+ }
67
+ let mut value = T::default();
68
+ let mut size = size_of::<T>() as u32;
69
+ let status = unsafe { AudioObjectGetPropertyData(device, address, 0, ptr::null(), &mut size, &mut value as *mut T as *mut c_void) };
70
+ (status == 0).then_some(value)
71
+ }
72
+
73
+ fn write<T>(device: u32, address: &Address, value: T, what: &str) -> Result<(), String> {
74
+ let status = unsafe { AudioObjectSetPropertyData(device, address, 0, ptr::null(), size_of::<T>() as u32, &value as *const T as *const c_void) };
75
+ if status != 0 {
76
+ return Err(format!("INTERNAL: cannot set the {what} (OSStatus {status})"));
77
+ }
78
+ Ok(())
79
+ }
80
+
81
+ fn settable(device: u32, address: &Address) -> bool {
82
+ let mut flag = 0u8;
83
+ unsafe { AudioObjectHasProperty(device, address) != 0 && AudioObjectIsPropertySettable(device, address, &mut flag) == 0 && flag != 0 }
84
+ }
85
+
86
+ /// A level with the float noise of f32 taken off (0.5 reads back as 0.49999997).
87
+ pub fn level_json(level: Option<f32>) -> String {
88
+ level.map_or("null".into(), |v| format!("{}", (f64::from(v) * 1000.0).round() / 1000.0))
89
+ }
90
+
91
+ pub fn state_json(level: Option<f32>, muted: bool, settable: bool) -> String {
92
+ format!(r#"{{"level":{},"muted":{},"settable":{}}}"#, level_json(level), muted, settable && level.is_some())
93
+ }
94
+
95
+ fn state(device: u32) -> String {
96
+ let level = read::<f32>(device, &VOLUME);
97
+ let muted = read::<u32>(device, &MUTE).is_some_and(|m| m != 0);
98
+ state_json(level, muted, settable(device, &VOLUME))
99
+ }
100
+
101
+ fn set(cmd: &json::V) -> Result<String, String> {
102
+ let level = cmd.get("level").and_then(|v| v.as_f64()).filter(|v| (0.0..=1.0).contains(v)).ok_or("level must be a number from 0 to 1")?;
103
+ let device = default_output()?;
104
+ if !settable(device, &VOLUME) {
105
+ return Err("UNSUPPORTED: this audio output takes no volume from the computer".into());
106
+ }
107
+ write(device, &VOLUME, level as f32, "volume")?;
108
+ Ok(state(device))
109
+ }
110
+
111
+ fn mute(cmd: &json::V) -> Result<String, String> {
112
+ let muted = cmd.get("muted").and_then(|v| v.as_bool()).ok_or("muted must be a boolean")?;
113
+ let device = default_output()?;
114
+ if !settable(device, &MUTE) {
115
+ return Err("UNSUPPORTED: this audio output cannot be muted from the computer".into());
116
+ }
117
+ write(device, &MUTE, u32::from(muted), "mute")?;
118
+ Ok(state(device))
119
+ }
120
+
121
+ extern "C" fn changed(_id: u32, _count: u32, _addresses: *const Address, _client: *mut c_void) -> i32 {
122
+ if let Ok(device) = default_output() {
123
+ listen(device);
124
+ }
125
+ push_event(r#"{"type":"volume","event":"changed"}"#);
126
+ 0
127
+ }
128
+
129
+ /// Listens to a device's volume and mute once; the lock is not held while CoreAudio is called.
130
+ fn listen(device: u32) {
131
+ let fresh = WATCHED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashSet::new).insert(device);
132
+ if fresh {
133
+ for address in [&VOLUME, &MUTE] {
134
+ unsafe { AudioObjectAddPropertyListener(device, address, changed, ptr::null_mut()) };
135
+ }
136
+ }
137
+ }
138
+
139
+ fn watch() -> Result<String, String> {
140
+ let first = {
141
+ let mut watched = WATCHED.lock().unwrap_or_else(|e| e.into_inner());
142
+ let first = watched.is_none();
143
+ watched.get_or_insert_with(HashSet::new);
144
+ first
145
+ };
146
+ if first {
147
+ unsafe { AudioObjectAddPropertyListener(SYSTEM, &DEFAULT_OUTPUT, changed, ptr::null_mut()) };
148
+ }
149
+ if let Ok(device) = default_output() {
150
+ listen(device);
151
+ }
152
+ Ok("null".into())
153
+ }
154
+
155
+ /// volume.* shell ops; None for other ops.
156
+ pub fn shell_op(op: &str, cmd: &json::V) -> Option<Result<String, String>> {
157
+ let result = match op {
158
+ "volume.get" => default_output().map(state),
159
+ "volume.set" => set(cmd),
160
+ "volume.mute" => mute(cmd),
161
+ "volume.watch" => watch(),
162
+ _ if op.starts_with("volume.") => Err(format!("unknown op {op}")),
163
+ _ => return None,
164
+ };
165
+ Some(result)
166
+ }
167
+
168
+ #[cfg(test)]
169
+ mod tests {
170
+ use super::*;
171
+
172
+ #[test]
173
+ fn state_shape() {
174
+ assert_eq!(state_json(Some(0.49999997), false, true), r#"{"level":0.5,"muted":false,"settable":true}"#);
175
+ assert_eq!(state_json(None, true, true), r#"{"level":null,"muted":true,"settable":false}"#);
176
+ assert!(json::parse(&state_json(Some(1.0), false, false)).is_ok());
177
+ }
178
+
179
+ #[test]
180
+ fn four_char_codes() {
181
+ assert_eq!(code(b"dOut"), 0x644f_7574);
182
+ assert_eq!(code(b"vmvc"), 0x766d_7663);
183
+ }
184
+ }
@@ -22,6 +22,7 @@ pub mod open;
22
22
  pub mod power;
23
23
  pub mod registry;
24
24
  mod screen;
25
+ mod volume;
25
26
  mod tray;
26
27
 
27
28
  use std::ffi::c_void;
@@ -43,12 +44,13 @@ pub fn shell_op(op: &str, cmd: &json::V, target: &EventLoopWindowTarget<UserEven
43
44
  .or_else(|| device::shell_op(op))
44
45
  // keychain.*: secure-storage (the Credential Manager)
45
46
  .or_else(|| keychain::shell_op(op, cmd))
46
- // menu.*, tray.*, hotkey.*: global-shortcut; dock.*: dock and badge; screen.*
47
+ // menu.*, tray.*, hotkey.*: global-shortcut; dock.*: dock and badge; screen.*; volume.*
47
48
  .or_else(|| menu::shell_op(op, cmd, &shell.title))
48
49
  .or_else(|| tray::shell_op(op, cmd, &shell.app_dir, &shell.title))
49
50
  .or_else(|| hotkey::shell_op(op, cmd))
50
51
  .or_else(|| dock::shell_op(op, cmd))
51
52
  .or_else(|| screen::shell_op(op, target))
53
+ .or_else(|| volume::shell_op(op, cmd))
52
54
  }
53
55
 
54
56
  /// Ops on one window. None: not handled here.