fix: the menu bar could never read a browser's address

Tested end to end from the menu bar: a video playing in Arc was saved,
its channel subscribed to, and the subscription removed again.

Three things were wrong.

One script named all seven browsers. AppleScript resolves an
application's terminology when it compiles, so naming a browser that is
not installed is a compile error — which no `try` can catch, and which
kills the whole script before a line of it runs. On a Mac without Brave
the lookup failed outright and Arc, first in the list and working, was
never asked. Each browser now gets its own script.

The tray icon was dropped as soon as it was built. TrayIcon is
reference-counted and "the icon is removed when the last instance is
dropped", so it was created and destroyed in the same breath. It is
held for the life of the app.

Subscribing to a channel already present as a bare row — the parent of
a video saved from the menu bar — was refused as a duplicate, and the
upsert would not have promoted it anyway. Saving a video from a channel
therefore made it impossible to subscribe to. The check asks whether it
is a subscription, not whether the row exists, and subscribing promotes.

Feedback no longer depends on one channel that can fail silently: a
notification from the app, the AppleScript one behind it, and the
menu's own first line, which reports the last result and cannot be
suppressed. A log beside the database records each step, which is how
all three of these were found rather than guessed at.
This commit is contained in:
vincent
2026-08-29 20:27:52 +02:00
parent 1790453a47
commit 832a8f0f26
11 changed files with 734 additions and 101 deletions
+124 -7
View File
@@ -510,7 +510,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.1", "cpufeatures 0.3.1",
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1156,6 +1156,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
] ]
@@ -1459,7 +1460,7 @@ dependencies = [
"js-sys", "js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core", "rand_core 0.10.1",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -2327,6 +2328,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2451,6 +2466,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2965,6 +2994,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -3087,7 +3125,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.4.3", "getrandom 0.4.3",
"lru-slab", "lru-slab",
"rand", "rand 0.10.2",
"rand_pcg", "rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
@@ -3111,7 +3149,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3135,6 +3173,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.2"
@@ -3143,7 +3191,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core", "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
] ]
[[package]] [[package]]
@@ -3158,7 +3225,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -3424,7 +3491,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4228,6 +4295,25 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.5",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4350,6 +4436,17 @@ dependencies = [
"toml 1.1.4+spec-1.1.0", "toml 1.1.4+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5772,6 +5869,26 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
+1
View File
@@ -29,6 +29,7 @@ chrono = "0.4.45"
tokio = { version = "1.53.1", features = ["full"] } tokio = { version = "1.53.1", features = ["full"] }
futures = "0.3.34" futures = "0.3.34"
tauri-plugin-dialog = "2.7.2" tauri-plugin-dialog = "2.7.2"
tauri-plugin-notification = "2"
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
+2 -1
View File
@@ -10,6 +10,7 @@
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-is-fullscreen" "core:window:allow-is-fullscreen",
"notification:default"
] ]
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen"]}} {"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default"]}}
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",
+1 -1
View File
@@ -1696,7 +1696,7 @@ pub async fn add_channel(
return Err("No channel found at that link. A channel page or one of its videos works best.".into()); return Err("No channel found at that link. A channel page or one of its videos works best.".into());
}; };
if state.db.lock().await.has_channel(&id)? { if state.db.lock().await.is_subscribed(&id)? {
return Err(format!("{title} is already in your subscriptions.")); return Err(format!("{title} is already in your subscriptions."));
} }
+41 -10
View File
@@ -288,8 +288,15 @@ impl Db {
let mut stmt = tx let mut stmt = tx
.prepare( .prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) "INSERT INTO channels (id, title, url, added_at, subscribed)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
url = excluded.url,
-- A channel saved from the menu bar exists as a bare
-- row; subscribing to it now is a promotion, not a
-- duplicate.
subscribed = 1",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ts = now(); let ts = now();
@@ -349,10 +356,15 @@ impl Db {
tx.commit().map_err(|e| e.to_string()) tx.commit().map_err(|e| e.to_string())
} }
/// True when a channel is already subscribed. /// True when a channel is already a subscription. A bare row saved for a
pub fn has_channel(&self, id: &str) -> Result<bool, String> { /// one-off video does not count: subscribing to it is a real change.
pub fn is_subscribed(&self, id: &str) -> Result<bool, String> {
self.conn self.conn
.query_row("SELECT 1 FROM channels WHERE id = ?1", params![id], |_| Ok(())) .query_row(
"SELECT 1 FROM channels WHERE id = ?1 AND subscribed = 1",
params![id],
|_| Ok(()),
)
.map(|_| true) .map(|_| true)
.or_else(|e| match e { .or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(false), rusqlite::Error::QueryReturnedNoRows => Ok(false),
@@ -366,8 +378,15 @@ impl Db {
{ {
let mut stmt = tx let mut stmt = tx
.prepare( .prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) "INSERT INTO channels (id, title, url, added_at, subscribed)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
url = excluded.url,
-- A channel saved from the menu bar exists as a bare
-- row; subscribing to it now is a promotion, not a
-- duplicate.
subscribed = 1",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ts = now(); let ts = now();
@@ -1167,7 +1186,7 @@ mod tests {
db.ensure_channel(&one_off).unwrap(); db.ensure_channel(&one_off).unwrap();
// It exists as a parent for the video, but it is not in the sidebar and // It exists as a parent for the video, but it is not in the sidebar and
// refreshing does not go looking for it. // refreshing does not go looking for it.
assert!(db.has_channel("UCX").unwrap()); assert!(!db.is_subscribed("UCX").unwrap());
assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX")); assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string())); assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string()));
@@ -1178,7 +1197,19 @@ mod tests {
url: "https://youtube.com/channel/UC1".into(), url: "https://youtube.com/channel/UC1".into(),
}]; }];
db.replace_channels(&incoming).unwrap(); db.replace_channels(&incoming).unwrap();
assert!(db.has_channel("UCX").unwrap()); assert_eq!(
db.conn
.query_row("SELECT COUNT(*) FROM channels WHERE id='UCX'", [], |r| r
.get::<_, i64>(0))
.unwrap(),
1
);
// Subscribing to it later promotes the row rather than being refused
// as a duplicate — saving a video is not subscribing.
db.upsert_channels(&[one_off.clone()]).unwrap();
assert!(db.is_subscribed("UCX").unwrap());
assert!(db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
} }
#[test] #[test]
@@ -1191,7 +1222,7 @@ mod tests {
assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]); assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]);
db.delete_channel("UC1").unwrap(); db.delete_channel("UC1").unwrap();
assert!(!db.has_channel("UC1").unwrap()); assert!(!db.is_subscribed("UC1").unwrap());
assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a")); assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a"));
} }
+1
View File
@@ -84,6 +84,7 @@ pub fn run() {
.menu(build_menu) .menu(build_menu)
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.on_menu_event(tray::on_menu_event) .on_menu_event(tray::on_menu_event)
.setup(|app| { .setup(|app| {
let state = commands::build_state(&app.handle().clone())?; let state = commands::build_state(&app.handle().clone())?;
+166 -80
View File
@@ -10,13 +10,52 @@
use crate::commands::{self, AppState}; use crate::commands::{self, AppState};
use crate::resolve; use crate::resolve;
use std::io::Write;
use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt;
pub const SAVE_VIDEO: &str = "tray.save_video"; pub const SAVE_VIDEO: &str = "tray.save_video";
pub const ADD_CHANNEL: &str = "tray.add_channel"; pub const ADD_CHANNEL: &str = "tray.add_channel";
pub const SHOW: &str = "tray.show"; pub const SHOW: &str = "tray.show";
pub const STATUS: &str = "tray.status";
/// A running account of what the menu bar did, next to the database.
///
/// The menu bar acts with no window open and its answers arrive as
/// notifications, which macOS can quietly withhold. When someone reports that
/// nothing happened, this is the difference between guessing and knowing.
fn log(app: &AppHandle, line: &str) {
let Ok(dir) = app.path().app_data_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("tray.log"))
{
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(f, "{secs} {line}");
}
}
/// The menu's own first line, kept so its text can be rewritten. A TrayIcon
/// does not hand its menu back, so the item is held here.
pub struct StatusItem(pub MenuItem<tauri::Wry>);
/// The tray icon itself, held for as long as the app runs. Dropping it takes
/// the icon out of the menu bar.
pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon<tauri::Wry>);
/// Leaves the answer on the menu itself, which no permission can suppress.
fn set_status(app: &AppHandle, text: &str) {
if let Some(item) = app.try_state::<StatusItem>() {
let _ = item.0.set_text(text);
}
}
/// Browsers worth asking, in the order they are asked. All but Safari answer /// Browsers worth asking, in the order they are asked. All but Safari answer
/// the same Chromium-flavoured AppleScript. /// the same Chromium-flavoured AppleScript.
@@ -29,71 +68,94 @@ const CHROMIUM: [&str; 6] = [
"Chromium", "Chromium",
]; ];
/// One browser's script. Chromium-derived browsers share a dictionary; Safari
/// speaks of documents rather than tabs.
fn url_script(browser: &str, safari_style: bool) -> String {
let getter = if safari_style {
"URL of front document"
} else {
"URL of active tab of front window"
};
format!(
r#"try
if running of application "{browser}" then
tell application "{browser}" to return {getter}
end if
end try
return ""
"#
)
}
/// The YouTube address showing in whichever browser has one. /// The YouTube address showing in whichever browser has one.
/// ///
/// Only browsers that are already running are asked, so nothing is launched to /// Each browser gets its own script, and that is the point rather than a
/// answer the question, and every lookup is wrapped in `try` — a browser with /// tidiness choice. AppleScript resolves an application's terminology when it
/// no window open must not turn into an error. /// compiles, so naming a browser that is not installed is a *compile* error
fn browser_url_script() -> String { /// which no `try` can catch, and which kills the whole script. One script
let mut s = String::from( /// covering six browsers therefore failed outright on a Mac missing any one of
r#"set found to "" /// them, and the first, working browser was never asked.
set apps to {} ///
try /// `running of application` needs no dictionary, so it answers even for a
tell application "System Events" to set apps to name of every process /// browser that is not installed, and launches nothing.
end try async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
"#, let mut refused = false;
);
for b in CHROMIUM { for (browser, safari_style) in CHROMIUM
s.push_str(&format!( .iter()
r#"if found is "" and apps contains "{b}" then .map(|b| (*b, false))
try .chain(std::iter::once(("Safari", true)))
tell application "{b}" to set u to URL of active tab of front window {
if u contains "youtube.com" or u contains "youtu.be" then set found to u let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
end try .arg("-e")
end if .arg(url_script(browser, safari_style))
"# .output()
)); .await
else {
continue;
};
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !stdout.is_empty() || !stderr.is_empty() {
log(app, &format!("{browser}: out={stdout:?} err={stderr:?}"));
}
// -1743 is macOS refusing the Apple event for want of permission.
if stderr.contains("-1743") || stderr.contains("Not authorized") {
refused = true;
}
if resolve::is_youtube_url(&stdout) {
return Ok(stdout);
}
} }
s.push_str(
r#"if found is "" and apps contains "Safari" then if refused {
try return Err("FlightTube needs permission to read your browser's address. \
tell application "Safari" to set u to URL of front document Allow it under System Settings Privacy & Security Automation."
if u contains "youtube.com" or u contains "youtu.be" then set found to u .into());
end try }
end if Err("No YouTube page open in a browser.".into())
return found
"#,
);
s
} }
async fn browser_youtube_url() -> Result<String, String> { /// The answer, delivered without the window coming forward.
let out = tokio::process::Command::new("/usr/bin/osascript") ///
.arg("-e") /// Three ways, because the first two can fail silently: a notification from the
.arg(browser_url_script()) /// app itself, the same through AppleScript if the plugin is unavailable, and
.output() /// the menu's own first line, which always survives.
.await async fn notify(app: &AppHandle, text: &str) {
.map_err(|e| format!("Could not ask the browser: {e}"))?; log(app, text);
set_status(app, text);
let stderr = String::from_utf8_lossy(&out.stderr); if app
// -1743 is macOS refusing the Apple event because the permission has not .notification()
// been granted. Saying so beats "nothing found". .builder()
if stderr.contains("-1743") || stderr.contains("Not authorized") { .title("FlightTube")
return Err( .body(text)
"FlightTube needs permission to read your browser's address. \ .show()
Allow it under System Settings Privacy & Security Automation." .is_ok()
.into(), {
); return;
} }
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() {
return Err("No YouTube page open in a browser.".into());
}
Ok(url)
}
/// A notification, so the answer arrives without the window coming forward.
async fn notify(text: &str) {
let script = format!( let script = format!(
"display notification {} with title \"FlightTube\"", "display notification {} with title \"FlightTube\"",
applescript_string(text) applescript_string(text)
@@ -112,46 +174,50 @@ fn applescript_string(s: &str) -> String {
} }
async fn handle_save_video(app: AppHandle) { async fn handle_save_video(app: AppHandle) {
let url = match browser_youtube_url().await { let app = &app;
log(app, "menu: save video");
let url = match browser_youtube_url(app).await {
Ok(u) => u, Ok(u) => u,
Err(e) => return notify(&e).await, Err(e) => return notify(app, &e).await,
}; };
if resolve::video_id_from_url(&url).is_none() { if resolve::video_id_from_url(&url).is_none() {
return notify("That page is not a video.").await; return notify(app, "That page is not a video.").await;
} }
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let (video_id, title) = match commands::save_video(&state, &url).await { let (video_id, title) = match commands::save_video(&state, &url).await {
Ok(v) => v, Ok(v) => v,
Err(e) => return notify(&e).await, Err(e) => return notify(app, &e).await,
}; };
let (quality, sub_lang) = state.download_defaults.lock().await.clone(); let (quality, sub_lang) = state.download_defaults.lock().await.clone();
let _ = app.emit("feed:changed", ()); let _ = app.emit("feed:changed", ());
notify(&format!("Downloading {title}")).await; notify(app, &format!("Downloading {title}")).await;
// The download outlives this handler; its progress shows in the window. // The download outlives this handler; its progress shows in the window.
let handle = app.clone(); let handle = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle.state::<AppState>(); let state = handle.state::<AppState>();
match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await { match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await {
Ok(()) => notify(&format!("Saved {title}")).await, Ok(()) => notify(&handle, &format!("Saved {title}")).await,
Err(e) => notify(&format!("{title} failed: {e}")).await, Err(e) => notify(&handle, &format!("{title} failed: {e}")).await,
} }
}); });
} }
async fn handle_add_channel(app: AppHandle) { async fn handle_add_channel(app: AppHandle) {
let url = match browser_youtube_url().await { let app = &app;
log(app, "menu: add channel");
let url = match browser_youtube_url(app).await {
Ok(u) => u, Ok(u) => u,
Err(e) => return notify(&e).await, Err(e) => return notify(app, &e).await,
}; };
let state = app.state::<AppState>(); let state = app.state::<AppState>();
match commands::add_channel(url, app.clone(), state).await { match commands::add_channel(url, app.clone(), state).await {
Ok(title) => { Ok(title) => {
let _ = app.emit("feed:changed", ()); let _ = app.emit("feed:changed", ());
notify(&format!("Subscribed to {title}")).await; notify(app, &format!("Subscribed to {title}")).await;
} }
Err(e) => notify(&e).await, Err(e) => notify(app, &e).await,
} }
} }
@@ -178,9 +244,13 @@ pub fn on_menu_event(app: &AppHandle, event: MenuEvent) {
} }
pub fn build(app: &AppHandle) -> tauri::Result<()> { pub fn build(app: &AppHandle) -> tauri::Result<()> {
// Disabled: it reports, it does not act.
let status = MenuItem::with_id(app, STATUS, "FlightTube", false, None::<&str>)?;
let menu = Menu::with_items( let menu = Menu::with_items(
app, app,
&[ &[
&status,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?, &MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?,
&MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?, &MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
@@ -192,7 +262,7 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
TrayIconBuilder::with_id("flighttube") let tray = TrayIconBuilder::with_id("flighttube")
.icon(icon) .icon(icon)
// A template image takes the menu bar's own colour, light or dark. // A template image takes the menu bar's own colour, light or dark.
.icon_as_template(true) .icon_as_template(true)
@@ -201,6 +271,13 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
// The menu is the whole point; a left click should open it too. // The menu is the whole point; a left click should open it too.
.show_menu_on_left_click(true) .show_menu_on_left_click(true)
.build(app)?; .build(app)?;
// TrayIcon is reference-counted and "the icon is removed when the last
// instance is dropped" — letting the handle fall out of scope here created
// the item and destroyed it in the same breath, which is exactly as
// invisible as never creating it.
app.manage(Tray(tray));
app.manage(StatusItem(status));
Ok(()) Ok(())
} }
@@ -209,17 +286,26 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn the_script_asks_every_browser_and_launches_none() { fn each_browser_gets_its_own_script() {
let s = browser_url_script(); let arc = url_script("Arc", false);
for b in CHROMIUM { // Naming a browser that is not installed is a compile error, so a
assert!(s.contains(&format!("apps contains \"{b}\"")), "missing {b}"); // script must never mention more than the one it is asking.
for other in CHROMIUM.iter().filter(|b| **b != "Arc") {
assert!(!arc.contains(*other), "Arc's script mentions {other}");
} }
assert!(s.contains("Safari")); assert!(!arc.contains("Safari"));
// Guarded by the running-process list, so asking cannot start a browser. // Behind a running check, so asking cannot start a browser, and inside
assert_eq!(s.matches("apps contains").count(), CHROMIUM.len() + 1); // a try, so a browser with no window is not an error.
// Every lookup is inside a try, so a browser with no windows is not an assert!(arc.contains(r#"running of application "Arc""#));
// error: one per browser, one for Safari, one for the process list. assert!(arc.contains("end try"));
assert_eq!(s.matches("end try").count(), CHROMIUM.len() + 2); // Nothing needs System Events, which is a separate permission.
assert!(!arc.contains("System Events"));
}
#[test]
fn safari_is_asked_in_its_own_dialect() {
assert!(url_script("Safari", true).contains("URL of front document"));
assert!(url_script("Arc", false).contains("URL of active tab of front window"));
} }
#[test] #[test]