Say when something is downloading, and where it goes
The webview saves the file perfectly well and mentions it to nobody, which makes a download indistinguishable from a click that did nothing. Each one is now announced in the corner as it saves, and offers to show the finished file in the Finder. The folder is settable and defaults to the system's Downloads. Two gaps in the API shape this. There is no progress - DownloadEvent reports a start and a finish and nothing between - so the destination file is polled as it grows and the bytes written are shown; the indicator spins rather than fills, because there is no total to divide by. And on macOS the finish always reports no path at all, so the destination assigned at request time is remembered against the URL and read back at the end. A name already taken gets "(2)" appended. Silently overwriting is the last thing anyone wants from a download they were not told about.
This commit is contained in:
@@ -343,6 +343,26 @@ on a blocking worker rather than the calling thread — its completion handler r
|
||||
main thread, and waiting for it *there* deadlocks until the timeout and returns nothing
|
||||
every time.
|
||||
|
||||
## Downloads
|
||||
|
||||
The webview saves a file perfectly well and mentions it to nobody, which makes a download
|
||||
indistinguishable from a click that did nothing. So each one is announced in the corner as
|
||||
it saves, and offers to show the finished file in the Finder.
|
||||
|
||||
Two things the API does not give, and how each is handled:
|
||||
|
||||
- **No progress.** `DownloadEvent` reports a start and a finish and nothing between — no
|
||||
byte count, no total. The destination is known, though, so the file is polled as it grows
|
||||
and the bytes written are shown. There is no percentage because there is no total to
|
||||
divide by, so the indicator spins rather than fills.
|
||||
- **No path at the end.** On macOS `Finished` always reports `path: None`. The destination
|
||||
assigned at `Requested` is therefore remembered against the URL, and read back when the
|
||||
download finishes.
|
||||
|
||||
The folder is configurable and defaults to the system's Downloads. A name already taken
|
||||
gets `(2)` appended, the way a browser does — silently overwriting is the last thing anyone
|
||||
wants from a download they were not told about.
|
||||
|
||||
## Passwords
|
||||
|
||||
Submitting a form that contains a password offers to remember it, the way a browser does.
|
||||
|
||||
Generated
+10
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"react": "^19.1.0",
|
||||
@@ -1672,6 +1673,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz",
|
||||
"integrity": "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"react": "^19.1.0",
|
||||
|
||||
Generated
+142
-1
@@ -2925,6 +2925,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3643,6 +3667,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.20",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.20",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.4.0"
|
||||
@@ -4736,6 +4802,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4769,13 +4844,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4806,6 +4898,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4818,6 +4916,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4830,12 +4934,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4848,6 +4964,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4860,6 +4982,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4872,6 +5000,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4884,6 +5018,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -4939,6 +5079,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
|
||||
@@ -23,6 +23,7 @@ tokio = { version = "1", features = ["time"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"notification:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default","notification:default","window-state:default"]}}
|
||||
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default","notification:default","window-state:default","dialog:default"]}}
|
||||
@@ -2360,6 +2360,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"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",
|
||||
|
||||
@@ -2360,6 +2360,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"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",
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct AppState {
|
||||
/// When each app last raised a notification in its own words, so a count
|
||||
/// does not immediately say the same thing again in worse words.
|
||||
pub last_spoke: Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
/// Downloads that have finished, so their watcher can stop looking.
|
||||
pub finished_downloads: Mutex<std::collections::HashSet<u64>>,
|
||||
/// Where each download was sent, by URL — macOS never reports the path
|
||||
/// back when it finishes, so it has to be remembered from the request.
|
||||
pub download_paths: Mutex<std::collections::HashMap<String, (u64, PathBuf)>>,
|
||||
/// A login waiting on an answer: host, account, password. Held only until
|
||||
/// it is saved or declined, and never written anywhere but the Keychain.
|
||||
pub pending_password: Mutex<Option<(String, String, String, String)>>,
|
||||
@@ -88,6 +93,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
radius: Mutex::new(0.0),
|
||||
chrome: Mutex::new(0.0),
|
||||
rail: Mutex::new(72.0),
|
||||
finished_downloads: Mutex::new(std::collections::HashSet::new()),
|
||||
download_paths: Mutex::new(std::collections::HashMap::new()),
|
||||
last_spoke: Mutex::new(std::collections::HashMap::new()),
|
||||
pending_password: Mutex::new(None),
|
||||
config: Mutex::new(config),
|
||||
@@ -879,6 +886,35 @@ pub fn reset_config(app: AppHandle, state: State<'_, AppState>) -> Result<Config
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- downloads
|
||||
|
||||
/// The folder downloads go to, for showing in Settings.
|
||||
#[tauri::command]
|
||||
pub fn download_folder(app: AppHandle) -> String {
|
||||
crate::downloads::folder(&app).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_download_folder(
|
||||
path: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Config, String> {
|
||||
state.config.lock().unwrap().settings.download_dir = path;
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Shows a finished download where it landed, selected, in the Finder.
|
||||
#[tauri::command]
|
||||
pub fn reveal(path: String) -> Result<(), String> {
|
||||
std::process::Command::new("/usr/bin/open")
|
||||
.arg("-R")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not show {path}: {e}"))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ passwords
|
||||
|
||||
/// Writes the offered login to the macOS Keychain.
|
||||
|
||||
@@ -94,6 +94,9 @@ pub struct Settings {
|
||||
/// rather hear only what an app says in its own words.
|
||||
#[serde(default = "yes")]
|
||||
pub count_notifications: bool,
|
||||
/// Where downloads land. Unset means the system's Downloads folder.
|
||||
#[serde(default)]
|
||||
pub download_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
@@ -111,6 +114,7 @@ impl Default for Settings {
|
||||
theme: default_theme(),
|
||||
last_app: None,
|
||||
count_notifications: true,
|
||||
download_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Downloads: where they go, and telling someone they happened.
|
||||
//!
|
||||
//! A download that leaves no trace is indistinguishable from a click that did
|
||||
//! nothing. WebKit saves the file perfectly well on its own — what it does not
|
||||
//! do is say so.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadStarted {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadProgress {
|
||||
pub id: u64,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadFinished {
|
||||
pub id: u64,
|
||||
pub success: bool,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Where downloads land: the configured folder, or ~/Downloads.
|
||||
pub fn folder(handle: &AppHandle) -> PathBuf {
|
||||
let configured = handle
|
||||
.state::<crate::commands::AppState>()
|
||||
.cfg()
|
||||
.settings
|
||||
.download_dir;
|
||||
|
||||
configured
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_dir())
|
||||
.unwrap_or_else(|| {
|
||||
handle
|
||||
.path()
|
||||
.download_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
})
|
||||
}
|
||||
|
||||
/// A name not already taken, the way a browser does it: `report (2).pdf`.
|
||||
///
|
||||
/// Overwriting silently is the one outcome nobody wants from a download they
|
||||
/// were not told about in the first place.
|
||||
pub fn unique(dir: &std::path::Path, name: &str) -> PathBuf {
|
||||
let candidate = dir.join(name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
for n in 2..1000 {
|
||||
let next = dir.join(format!("{stem} ({n}){ext}"));
|
||||
if !next.exists() {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// The filename a URL suggests, or something honest when it suggests nothing.
|
||||
pub fn name_from(url: &url::Url, fallback: &std::path::Path) -> String {
|
||||
let from_path = url
|
||||
.path_segments()
|
||||
.and_then(|s| s.last())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
percent_decode(s)
|
||||
});
|
||||
|
||||
from_path
|
||||
.or_else(|| {
|
||||
fallback
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.filter(|f| !f.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "download".into())
|
||||
}
|
||||
|
||||
/// Percent-decoding, without a crate for it: a filename in a URL is escaped,
|
||||
/// and `My%20Report.pdf` is not what anyone wants to find on disk.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
pub fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Watches the file grow, so there is something to look at while it does.
|
||||
///
|
||||
/// Polling the file is all there is: the webview reports that a download
|
||||
/// started and that it finished, and nothing in between — no byte count, no
|
||||
/// total, no percentage. The size on disk is the only honest progress
|
||||
/// available, so that is what gets shown.
|
||||
pub fn watch(handle: &AppHandle, id: u64, path: PathBuf) {
|
||||
let handle = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last = 0u64;
|
||||
for _ in 0..(60 * 30) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
|
||||
let done = {
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let finished = state.finished_downloads.lock().unwrap();
|
||||
finished.contains(&id)
|
||||
};
|
||||
|
||||
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(last);
|
||||
if bytes != last {
|
||||
last = bytes;
|
||||
let _ = handle.emit("download-progress", DownloadProgress { id, bytes });
|
||||
}
|
||||
if done {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod downloads;
|
||||
pub mod routing;
|
||||
pub mod webviews;
|
||||
|
||||
@@ -74,6 +75,7 @@ pub fn run() {
|
||||
})
|
||||
// Remembers where the window was and how big, across launches.
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
@@ -124,6 +126,9 @@ pub fn run() {
|
||||
commands::set_window_chrome,
|
||||
commands::chrome_height,
|
||||
commands::rail_width,
|
||||
commands::download_folder,
|
||||
commands::set_download_folder,
|
||||
commands::reveal,
|
||||
commands::unread_counts,
|
||||
commands::focus_window,
|
||||
commands::save_password,
|
||||
|
||||
@@ -686,6 +686,7 @@ pub fn create(
|
||||
|
||||
let load_handle = handle.clone();
|
||||
let load_id = id.clone();
|
||||
let dl_handle = handle.clone();
|
||||
|
||||
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
|
||||
.user_agent(&app.ua())
|
||||
@@ -716,6 +717,59 @@ pub fn create(
|
||||
}
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
// WebKit saves the file happily on its own; what it never does is
|
||||
// mention it. Redirected to the chosen folder, and announced.
|
||||
.on_download(move |_wv, event| {
|
||||
use tauri::webview::DownloadEvent;
|
||||
let state = dl_handle.state::<crate::commands::AppState>();
|
||||
|
||||
match event {
|
||||
DownloadEvent::Requested { url, destination } => {
|
||||
let dir = crate::downloads::folder(&dl_handle);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let name = crate::downloads::name_from(&url, destination);
|
||||
let path = crate::downloads::unique(&dir, &name);
|
||||
|
||||
let id = crate::downloads::next_id();
|
||||
state
|
||||
.download_paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(url.to_string(), (id, path.clone()));
|
||||
|
||||
let _ = dl_handle.emit(
|
||||
"download-started",
|
||||
crate::downloads::DownloadStarted {
|
||||
id,
|
||||
name,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
},
|
||||
);
|
||||
crate::downloads::watch(&dl_handle, id, path.clone());
|
||||
*destination = path;
|
||||
}
|
||||
|
||||
DownloadEvent::Finished { url, success, .. } => {
|
||||
// The path is never reported back on macOS, so it comes
|
||||
// from what was assigned when the download was requested.
|
||||
let found = state.download_paths.lock().unwrap().remove(&url.to_string());
|
||||
if let Some((id, path)) = found {
|
||||
state.finished_downloads.lock().unwrap().insert(id);
|
||||
let _ = dl_handle.emit(
|
||||
"download-finished",
|
||||
crate::downloads::DownloadFinished {
|
||||
id,
|
||||
success,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
})
|
||||
// The script carries a snapshot of the hidden list from when the view
|
||||
// was built, so anything chosen since would come back on reload. This
|
||||
// re-asserts the real list on every navigation.
|
||||
|
||||
+35
@@ -3,11 +3,13 @@ import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import * as api from "./api";
|
||||
import Nav from "./components/Nav";
|
||||
import Downloads from "./components/Downloads";
|
||||
import Settings from "./components/Settings";
|
||||
import { BTN_PRIMARY, Dialog } from "./components/ui";
|
||||
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
||||
import type {
|
||||
Config,
|
||||
Download,
|
||||
Group,
|
||||
HiddenEvent,
|
||||
NotificationClick,
|
||||
@@ -28,6 +30,7 @@ export default function App() {
|
||||
shell has to keep clear of it or the nav lands on the traffic lights. */
|
||||
const [chrome, setChrome] = useState(0);
|
||||
const [rail, setRail] = useState(72);
|
||||
const [downloads, setDownloads] = useState<Download[]>([]);
|
||||
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
|
||||
@@ -145,6 +148,33 @@ export default function App() {
|
||||
}),
|
||||
// A login was submitted. The password is held in Rust; this only asks.
|
||||
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
|
||||
|
||||
listen<{ id: number; name: string; path: string }>("download-started", (e) =>
|
||||
setDownloads((d) => [
|
||||
...d.filter((x) => x.id !== e.payload.id),
|
||||
{ ...e.payload, bytes: 0, state: "running" },
|
||||
]),
|
||||
),
|
||||
listen<{ id: number; bytes: number }>("download-progress", (e) =>
|
||||
setDownloads((d) =>
|
||||
d.map((x) => (x.id === e.payload.id ? { ...x, bytes: e.payload.bytes } : x)),
|
||||
),
|
||||
),
|
||||
listen<{ id: number; success: boolean; path: string }>("download-finished", (e) => {
|
||||
setDownloads((d) =>
|
||||
d.map((x) =>
|
||||
x.id === e.payload.id
|
||||
? { ...x, state: e.payload.success ? "done" : "failed", path: e.payload.path }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
// A finished download clears itself after a while; an unfinished one
|
||||
// stays, because it is still telling you something.
|
||||
setTimeout(
|
||||
() => setDownloads((d) => d.filter((x) => x.id !== e.payload.id)),
|
||||
12000,
|
||||
);
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
unlisten.forEach((p) => p.then((f) => f()));
|
||||
@@ -291,6 +321,11 @@ export default function App() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Downloads
|
||||
items={downloads}
|
||||
onDismiss={(id) => setDownloads((d) => d.filter((x) => x.id !== id))}
|
||||
/>
|
||||
|
||||
{settingsOpen && (
|
||||
<Settings
|
||||
config={config}
|
||||
|
||||
@@ -67,3 +67,8 @@ export const chromeHeight = () => invoke<number>("chrome_height");
|
||||
export const setCountNotifications = (enabled: boolean) =>
|
||||
invoke<Config>("set_count_notifications", { enabled });
|
||||
export const railWidth = () => invoke<number>("rail_width");
|
||||
|
||||
export const downloadFolder = () => invoke<string>("download_folder");
|
||||
export const setDownloadFolder = (path: string | null) =>
|
||||
invoke<Config>("set_download_folder", { path });
|
||||
export const reveal = (path: string) => invoke<void>("reveal", { path });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Download } from "../types";
|
||||
import * as api from "../api";
|
||||
|
||||
/**
|
||||
* What is being downloaded, and what just was.
|
||||
*
|
||||
* WebKit saves the file on its own and says nothing about it, so without this
|
||||
* a download is indistinguishable from a click that did nothing.
|
||||
*/
|
||||
export default function Downloads({
|
||||
items,
|
||||
onDismiss,
|
||||
}: {
|
||||
items: Download[];
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[80] flex w-[320px] flex-col gap-2">
|
||||
{items.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-xl border border-slate-200
|
||||
bg-white/95 px-3 py-2.5 shadow-xl backdrop-blur
|
||||
dark:border-slate-800 dark:bg-slate-900/95"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-slate-100 dark:bg-slate-800">
|
||||
{d.state === "done" ? (
|
||||
<svg viewBox="0 0 24 24" className="size-4 text-emerald-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : d.state === "failed" ? (
|
||||
<svg viewBox="0 0 24 24" className="size-4 text-red-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
) : (
|
||||
/* No total to divide by — the webview reports a start and a
|
||||
finish and nothing between — so this spins rather than fills. */
|
||||
<svg viewBox="0 0 24 24" className="size-4 animate-spin text-sky-500" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-medium">{d.name}</span>
|
||||
<span className="block text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{d.state === "done"
|
||||
? "Downloaded"
|
||||
: d.state === "failed"
|
||||
? "Download failed"
|
||||
: d.bytes > 0
|
||||
? `${formatBytes(d.bytes)} so far`
|
||||
: "Starting…"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{d.state === "done" && (
|
||||
<button
|
||||
onClick={() => api.reveal(d.path)}
|
||||
title="Show in Finder"
|
||||
className="shrink-0 cursor-pointer rounded-lg px-2 py-1 text-[11px] font-medium
|
||||
text-sky-600 hover:bg-sky-500/10 dark:text-sky-400"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDismiss(d.id)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 cursor-pointer rounded-lg p-1 text-slate-400 hover:text-slate-700
|
||||
dark:hover:text-slate-200"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import * as api from "../api";
|
||||
import type { Theme } from "../hooks/useAppearance";
|
||||
import type { Config, Group, WorkApp } from "../types";
|
||||
@@ -44,6 +46,7 @@ export default function Settings({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
||||
const [hiddenOpen, setHiddenOpen] = useState(false);
|
||||
const [folder, setFolder] = useState("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
|
||||
@@ -59,6 +62,10 @@ export default function Settings({
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
void api.downloadFolder().then(setFolder);
|
||||
}, [config.settings.downloadDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusHiddenFor) return;
|
||||
setHiddenOpen(true);
|
||||
@@ -263,6 +270,42 @@ export default function Settings({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------- downloads */}
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Downloads</SectionHeading>
|
||||
<div className={`${SUBPANEL} flex items-center gap-2`}>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{folder}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const picked = await open({ directory: true, multiple: false });
|
||||
if (typeof picked === "string") {
|
||||
await run(() => api.setDownloadFolder(picked));
|
||||
}
|
||||
}}
|
||||
className={BTN}
|
||||
>
|
||||
Choose…
|
||||
</button>
|
||||
<button onClick={() => api.reveal(folder)} className={BTN}>
|
||||
Open
|
||||
</button>
|
||||
{config.settings.downloadDir && (
|
||||
<button
|
||||
onClick={() => run(() => api.setDownloadFolder(null))}
|
||||
title="Back to the system Downloads folder"
|
||||
className={BTN}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={HELP}>
|
||||
A file that lands here is announced in the corner as it saves. Without that
|
||||
a download is indistinguishable from a click that did nothing — the webview
|
||||
saves it perfectly well and mentions it to nobody.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* --------------------------------------------- notifications */}
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Notifications</SectionHeading>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Settings {
|
||||
theme: "system" | "light" | "dark";
|
||||
lastApp: string | null;
|
||||
countNotifications: boolean;
|
||||
downloadDir: string | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -66,3 +67,12 @@ export interface PasswordOffer {
|
||||
host: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
/** A file the app is saving, or has just saved. */
|
||||
export interface Download {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
bytes: number;
|
||||
state: "running" | "done" | "failed";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user