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.
- package/base/baseEnv.ts +19 -5
- package/client/csrTypes.ts +2 -0
- package/client/device.ts +28 -12
- package/client/index.ts +1 -0
- package/client/native/accessibility.ts +1 -0
- package/client/native/app-state.ts +1 -0
- package/client/native/app.ts +1 -0
- package/client/native/appearance.ts +1 -0
- package/client/native/auth-session.ts +1 -0
- package/client/native/autostart.ts +1 -0
- package/client/native/badge.ts +1 -0
- package/client/native/biometric.ts +1 -0
- package/client/native/browser.ts +1 -0
- package/client/native/camera.ts +1 -0
- package/client/native/clipboard.ts +1 -0
- package/client/native/contacts.ts +1 -0
- package/client/native/device.ts +1 -0
- package/client/native/dialog.ts +1 -0
- package/client/native/dock.ts +1 -0
- package/client/native/file-picker.ts +1 -0
- package/client/native/filesystem.ts +1 -0
- package/client/native/geolocation.ts +1 -0
- package/client/native/global-shortcut.ts +1 -0
- package/client/native/haptics.ts +1 -0
- package/client/native/http.ts +1 -0
- package/client/native/iap.ts +1 -0
- package/client/native/keep-awake.ts +1 -0
- package/client/native/keyboard.ts +1 -0
- package/client/native/local-notifications.ts +1 -0
- package/client/native/menu.ts +1 -0
- package/client/native/network.ts +1 -0
- package/client/native/opener.ts +1 -0
- package/client/native/preferences.ts +1 -0
- package/client/native/push.ts +1 -0
- package/client/native/screen-orientation.ts +1 -0
- package/client/native/screen.ts +1 -0
- package/client/native/secure-storage.ts +1 -0
- package/client/native/share.ts +1 -0
- package/client/native/single-instance.ts +1 -0
- package/client/native/splash-screen.ts +1 -0
- package/client/native/sqlite.ts +1 -0
- package/client/native/toast.ts +1 -0
- package/client/native/tray.ts +1 -0
- package/client/native/updates.ts +1 -0
- package/client/native/volume.ts +1 -0
- package/client/native/window-state.ts +1 -0
- package/client/native/window.ts +1 -0
- package/client/native.ts +31 -1
- package/client/resolveServerUrl.ts +11 -0
- package/client/router.ts +8 -0
- package/common/Logger.ts +26 -2
- package/common/RouteLayering.ts +52 -0
- package/common/index.ts +1 -0
- package/index.ts +115 -6
- package/native/desktop.ts +5 -0
- package/package.json +11 -1
- package/server/akanApp.ts +23 -3
- package/server/akanAppHeaders.ts +4 -1
- package/server/akanServer.ts +27 -7
- package/server/index.ts +1 -0
- package/server/lifecycle/orphanGroup.ts +21 -0
- package/server/nativeFile.ts +107 -0
- package/server/ops/opsRoute.ts +3 -2
- package/server/routeTreeBuilder.ts +14 -39
- package/server/routing/apiRouter.ts +26 -3
- package/server/routing/hostAllowlist.ts +24 -0
- package/server/selfExec.ts +25 -0
- package/server/types.tsx +3 -1
- package/server/webRouter.ts +7 -4
- package/service/ipcTypes.ts +4 -0
- package/signal/CrossSiteGuard.ts +7 -0
- package/test/signalTestRuntime.ts +8 -2
- package/types/base/baseEnv.d.ts +1 -0
- package/types/client/csrTypes.d.ts +2 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/native/accessibility.d.ts +1 -0
- package/types/client/native/app-state.d.ts +1 -0
- package/types/client/native/app.d.ts +1 -0
- package/types/client/native/appearance.d.ts +1 -0
- package/types/client/native/auth-session.d.ts +1 -0
- package/types/client/native/autostart.d.ts +1 -0
- package/types/client/native/badge.d.ts +1 -0
- package/types/client/native/biometric.d.ts +1 -0
- package/types/client/native/browser.d.ts +1 -0
- package/types/client/native/camera.d.ts +1 -0
- package/types/client/native/clipboard.d.ts +1 -0
- package/types/client/native/contacts.d.ts +1 -0
- package/types/client/native/device.d.ts +1 -0
- package/types/client/native/dialog.d.ts +1 -0
- package/types/client/native/dock.d.ts +1 -0
- package/types/client/native/file-picker.d.ts +1 -0
- package/types/client/native/filesystem.d.ts +1 -0
- package/types/client/native/geolocation.d.ts +1 -0
- package/types/client/native/global-shortcut.d.ts +1 -0
- package/types/client/native/haptics.d.ts +1 -0
- package/types/client/native/http.d.ts +1 -0
- package/types/client/native/iap.d.ts +1 -0
- package/types/client/native/keep-awake.d.ts +1 -0
- package/types/client/native/keyboard.d.ts +1 -0
- package/types/client/native/local-notifications.d.ts +1 -0
- package/types/client/native/menu.d.ts +1 -0
- package/types/client/native/network.d.ts +1 -0
- package/types/client/native/opener.d.ts +1 -0
- package/types/client/native/preferences.d.ts +1 -0
- package/types/client/native/push.d.ts +1 -0
- package/types/client/native/screen-orientation.d.ts +1 -0
- package/types/client/native/screen.d.ts +1 -0
- package/types/client/native/secure-storage.d.ts +1 -0
- package/types/client/native/share.d.ts +1 -0
- package/types/client/native/single-instance.d.ts +1 -0
- package/types/client/native/splash-screen.d.ts +1 -0
- package/types/client/native/sqlite.d.ts +1 -0
- package/types/client/native/toast.d.ts +1 -0
- package/types/client/native/tray.d.ts +1 -0
- package/types/client/native/updates.d.ts +1 -0
- package/types/client/native/volume.d.ts +1 -0
- package/types/client/native/window-state.d.ts +1 -0
- package/types/client/native/window.d.ts +1 -0
- package/types/client/native.d.ts +14 -2
- package/types/client/resolveServerUrl.d.ts +1 -0
- package/types/client/router.d.ts +3 -0
- package/types/common/RouteLayering.d.ts +28 -0
- package/types/common/index.d.ts +1 -0
- package/types/index.d.ts +112 -6
- package/types/native/desktop.d.ts +3 -0
- package/types/server/akanAppHeaders.d.ts +2 -0
- package/types/server/index.d.ts +1 -0
- package/types/server/lifecycle/orphanGroup.d.ts +7 -0
- package/types/server/nativeFile.d.ts +10 -0
- package/types/server/routing/apiRouter.d.ts +3 -1
- package/types/server/routing/hostAllowlist.d.ts +8 -0
- package/types/server/selfExec.d.ts +8 -0
- package/types/server/types.d.ts +3 -1
- package/types/service/ipcTypes.d.ts +16 -0
- package/types/signal/CrossSiteGuard.d.ts +1 -0
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +57 -4
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +48 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.d.ts +8 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/prepare.d.ts +8 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +10 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +4 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/macos.d.ts +5 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +38 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/dispatcher.d.ts +8 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/grants.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/main.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +13 -2
- package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +27 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +21 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +79 -0
- package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +5 -0
- package/types/vendor/@akanjs/native/plugins/file-picker/src/index.d.ts +20 -1
- package/types/vendor/@akanjs/native/plugins/push/src/index.d.ts +9 -0
- package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +28 -0
- package/types/vendor/@akanjs/native/plugins/volume/src/common.d.ts +10 -0
- package/types/vendor/@akanjs/native/plugins/volume/src/desktop.d.ts +18 -0
- package/types/vendor/@akanjs/native/plugins/volume/src/index.d.ts +28 -0
- package/types/vendor/@akanjs/native/plugins/volume/src.d.ts +1 -0
- package/types/webkit/csrFrameDump.d.ts +42 -0
- package/types/webkit/nativeNavigation.d.ts +1 -0
- package/types/webkit/nativePurchase.d.ts +6 -2
- package/types/webkit/nativeUpdates.d.ts +8 -0
- package/ui/Agent/Attach.tsx +6 -2
- package/ui/CsrImage.tsx +3 -2
- package/ui/System/CSR.tsx +4 -0
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +5 -1
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeBridge.kt +19 -0
- package/vendor/@akanjs/native/native/desktop/Cargo.toml +1 -1
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +161 -25
- package/vendor/@akanjs/native/native/desktop/src/volume.rs +184 -0
- package/vendor/@akanjs/native/native/desktop/src/win/mod.rs +3 -1
- package/vendor/@akanjs/native/native/desktop/src/win/volume.rs +71 -0
- package/vendor/@akanjs/native/native/ios/Sources/AkanNativeShell.swift +12 -2
- package/vendor/@akanjs/native/packages/cli/src/api.ts +142 -12
- package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +5 -3
- package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +10 -71
- package/vendor/@akanjs/native/packages/cli/src/config.ts +41 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.ts +58 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/hmr.ts +2 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +10 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +38 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +104 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +85 -5
- package/vendor/@akanjs/native/packages/cli/src/platforms/android.ts +1 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +6 -4
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop.ts +16 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +44 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +436 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +2 -0
- package/vendor/@akanjs/native/packages/core/src/runtime.ts +26 -5
- package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +20 -0
- package/vendor/@akanjs/native/packages/desktop/src/grants.ts +47 -0
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +102 -14
- package/vendor/@akanjs/native/packages/desktop/src/main.ts +59 -11
- package/vendor/@akanjs/native/packages/desktop/src/paths.ts +38 -4
- package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +28 -0
- package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +159 -0
- package/vendor/@akanjs/native/packages/desktop/src/server.ts +493 -0
- package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +14 -0
- package/vendor/@akanjs/native/plugins/app/native-plugin.json +2 -2
- package/vendor/@akanjs/native/plugins/app/src/desktop.ts +5 -0
- package/vendor/@akanjs/native/plugins/app/src/index.ts +6 -1
- package/vendor/@akanjs/native/plugins/app/src/web.ts +3 -0
- package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +39 -2
- package/vendor/@akanjs/native/plugins/file-picker/src/index.ts +36 -1
- package/vendor/@akanjs/native/plugins/push/android/PushPlugin.kt +9 -1
- package/vendor/@akanjs/native/plugins/push/ios/PushPlugin.swift +4 -0
- package/vendor/@akanjs/native/plugins/push/src/index.ts +10 -0
- package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +464 -452
- package/vendor/@akanjs/native/plugins/volume/android/VolumePlugin.kt +107 -0
- package/vendor/@akanjs/native/plugins/volume/native-plugin.json +14 -0
- package/vendor/@akanjs/native/plugins/volume/src/common.ts +28 -0
- package/vendor/@akanjs/native/plugins/volume/src/desktop.ts +185 -0
- package/vendor/@akanjs/native/plugins/volume/src/index.ts +32 -0
- package/webkit/CsrRouteTable.ts +15 -35
- package/webkit/RenderLayer.tsx +71 -6
- package/webkit/bootCsr.tsx +11 -8
- package/webkit/csrFrameDump.ts +67 -0
- package/webkit/nativeNavigation.ts +17 -0
- package/webkit/nativePurchase.ts +12 -2
- package/webkit/nativeUpdates.ts +55 -0
- 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
|
|
1111
|
-
///
|
|
1112
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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,
|
|
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
|
|
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 {
|
|
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.
|
|
1893
|
-
win.
|
|
1894
|
-
|
|
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.
|