android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress notice — how many files, the one it is on, a bar, Stop all and Open — and takes it down when the queue empties. It sits on its own low-importance channel so it can be silenced without silencing the player's card, and rides its own dataSync foreground service rather than the media one: a mediaPlayback service stops itself when the bar empties, a download is not playback, and nothing else was keeping the process alive for a Save with nothing playing — which is how the first real Save died, four tracks in. Stop all goes through the same cancel(id) a row's tap takes, and Transfers grew the matching button beside Clear finished. The walk pushes two rows straight into the transfer store and reads the real notification back out of `dumpsys notification`, without fetching anything.
This commit is contained in:
1 parent
4fc6e9c630
commit
5738c2d7bf
31 files changed
+1358
-6
No files matched your search
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "tauri-plugin-transfer-notice"
|
||||
version = "0.1.0"
|
||||
description = "SoulStream's downloads in the phone's shade: a progress notification on its own low-importance channel, carried by its own dataSync foreground service, so a Save outlives the app going to the background"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
# required by tauri-plugin's build script; must equal the crate name
|
||||
links = "tauri-plugin-transfer-notice"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.soulstream.transfernotice"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// NotificationCompat, ServiceCompat, ContextCompat — no media here,
|
||||
// there is no session in this one
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- the foreground service that carries the downloads while the app is
|
||||
in the background; a fetch is dataSync, not playback -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- the notice is a notification; asked for at runtime on Android 13+ -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<!-- no intent filter: the notice's own buttons come back as
|
||||
onStartCommand intents on this very service -->
|
||||
<service
|
||||
android:name="com.soulstream.transfernotice.TransferService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.soulstream.transfernotice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
|
||||
/** What the notice says — the Rust `TransferNotice`, camelCase. */
|
||||
@InvokeArg
|
||||
class NoticeArgs {
|
||||
/** how many files are still in flight; zero means take the notice down */
|
||||
var count: Int = 0
|
||||
var title: String? = null
|
||||
var line: String? = null
|
||||
var percent: Int = 0
|
||||
/** nobody has stated a size yet, so the bar has no end to walk towards */
|
||||
var indeterminate: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge between the WebView and the service that owns the notice.
|
||||
* `update` mirrors what Save is fetching (and starts the service the first
|
||||
* time); `clear` takes the notice down. Its buttons come back as the plugin
|
||||
* event `action` with `{ action }` — "cancel" or "open".
|
||||
*/
|
||||
@TauriPlugin
|
||||
class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) {
|
||||
private var askedForNotifications = false
|
||||
|
||||
init {
|
||||
TransferHub.onAction = { action ->
|
||||
val payload = JSObject()
|
||||
payload.put("action", action)
|
||||
trigger("action", payload)
|
||||
}
|
||||
/* the channel exists from app start, so a user can silence the
|
||||
downloads on their own before the first one ever runs */
|
||||
TransferHub.ensureChannel(activity.applicationContext)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun update(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NoticeArgs::class.java)
|
||||
ensureNotificationPermission()
|
||||
TransferHub.update(activity.applicationContext, args)
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun clear(invoke: Invoke) {
|
||||
TransferHub.clear(activity.applicationContext)
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/** Android 13+: the notice is a notification and needs the permission;
|
||||
* asked once, the first time something is fetched. Its own request code
|
||||
* — 4711 is the media card's, and two requests that share one would
|
||||
* answer each other. The service runs either way; without the
|
||||
* permission it simply has nothing to show. */
|
||||
private fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
|
||||
askedForNotifications = true
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712)
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.soulstream.transfernotice
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* What is being fetched, shared between the plugin (which hears the
|
||||
* WebView) and the service (which owns the notice). The service is started
|
||||
* on the first update and refreshed on every one after; a count of zero is
|
||||
* a clear, since the phone's own rule is that an empty queue has no notice.
|
||||
*/
|
||||
object TransferHub {
|
||||
const val CHANNEL = "downloads"
|
||||
|
||||
/** 1 is the media card's, and ids are per-package */
|
||||
const val NOTIFICATION_ID = 2
|
||||
|
||||
const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL"
|
||||
const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN"
|
||||
|
||||
@Volatile var onAction: ((String) -> Unit)? = null
|
||||
@Volatile var state: NoticeArgs? = null
|
||||
@Volatile var service: TransferService? = null
|
||||
|
||||
/** Its own channel, low importance and no badge: a download is not an
|
||||
* alarm, and silencing it must not silence the player's card. A
|
||||
* filename is not for a locked screen, so the notice is private there. */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL) != null) return
|
||||
val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.description = "What Save is fetching into your library"
|
||||
channel.setShowBadge(false)
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun update(ctx: Context, np: NoticeArgs) {
|
||||
if (np.count <= 0) {
|
||||
clear(ctx)
|
||||
return
|
||||
}
|
||||
state = np
|
||||
val running = service
|
||||
if (running == null) {
|
||||
/* Android 12+ refuses a foreground start from the background. A
|
||||
Save is always begun from the foreground, so the first start
|
||||
lands; one that cannot simply leaves the fetch without a card */
|
||||
try {
|
||||
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "transfer notice could not start: $e")
|
||||
}
|
||||
} else {
|
||||
running.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
state = null
|
||||
val running = service
|
||||
/* refresh() on a null state is the one path that takes the notice
|
||||
down and stops the service; stopService is for a service that was
|
||||
never here */
|
||||
if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java))
|
||||
}
|
||||
|
||||
fun act(action: String) {
|
||||
onAction?.invoke(action)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The foreground service behind the notice: a progress notification with
|
||||
* Stop all and Open on it, and — the point — the reason the process is not
|
||||
* frozen while a Save runs with nothing playing. The fetches themselves
|
||||
* stay where they are, on the Rust core's own connections; this only says
|
||||
* what they are doing.
|
||||
*/
|
||||
class TransferService : Service() {
|
||||
private var inForeground = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
TransferHub.ensureChannel(this)
|
||||
TransferHub.service = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
/* Started with startForegroundService, so Android demands
|
||||
startForeground() promptly WHATEVER the state — a queue that
|
||||
emptied while the start was in flight included — or it kills the
|
||||
whole app about ten seconds later. MediaService learned this the
|
||||
same way, on 2026-09-13, and it was a Save that died. Promote
|
||||
first, with the notice there is or a plain one; refresh() steps
|
||||
back at once if there is nothing left to say. */
|
||||
if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs()))
|
||||
when (intent?.action) {
|
||||
TransferHub.ACTION_CANCEL -> TransferHub.act("cancel")
|
||||
TransferHub.ACTION_OPEN -> {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ }
|
||||
}
|
||||
TransferHub.act("open")
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/** Mirror TransferHub.state into the notice — or take it down. */
|
||||
fun refresh() {
|
||||
val np = TransferHub.state
|
||||
if (np == null) {
|
||||
/* the queue emptied: the notice goes, whole */
|
||||
if (inForeground) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
}
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
/* promote() on an already-promoted service is just a notify */
|
||||
promote(build(np))
|
||||
}
|
||||
|
||||
private fun promote(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ServiceCompat.startForeground(
|
||||
this, TransferHub.NOTIFICATION_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
startForeground(TransferHub.NOTIFICATION_ID, notification)
|
||||
}
|
||||
inForeground = true
|
||||
}
|
||||
|
||||
/** Android 15 caps a dataSync service at six hours a day and kills the
|
||||
* app if it is still up when this returns. Take the notice down: the
|
||||
* fetches are the Rust core's own and Transfers still lists them. */
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
TransferHub.clear(applicationContext)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
TransferHub.service = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun build(np: NoticeArgs): Notification {
|
||||
val open = serviceIntent(11, TransferHub.ACTION_OPEN)
|
||||
val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL)
|
||||
return NotificationCompat.Builder(this, TransferHub.CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_soulstream_down)
|
||||
.setContentTitle(np.title ?: "Saving")
|
||||
.setContentText(np.line ?: "")
|
||||
.setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate)
|
||||
.setContentIntent(open)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setOngoing(true)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel)
|
||||
.addAction(android.R.drawable.ic_menu_upload, "Open", open)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** The notice only exists while this service is in the foreground, so
|
||||
* its own buttons are never a background service start. The request
|
||||
* codes are 11 and 12 on purpose — the media plugin uses 0 in the same
|
||||
* package, and two PendingIntents that filterEqual overwrite each other. */
|
||||
private fun serviceIntent(code: Int, action: String): PendingIntent {
|
||||
val i = Intent(this, TransferService::class.java).setAction(action)
|
||||
return PendingIntent.getForegroundService(
|
||||
this, code, i,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- the status-bar glyph: an arrow into a tray, white on transparent, as
|
||||
the shade wants. Its own copy on purpose — R.drawable.ic_soulstream_note
|
||||
belongs to the media-session module's R class and is not visible here -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11,3h2v7h3.5L12,15.5 7.5,10H11V3z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,17.5h14v2.5H5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
// `update` and `clear` are ours. registerListener / removeListener are the
|
||||
// mobile plugin runtime's own commands, the ones `addPluginListener`
|
||||
// invokes to hear the `action` event — commands like any other to the ACL,
|
||||
// and without them here the listener is refused and the notice's buttons go
|
||||
// nowhere. The media-session plugin carries the same scar.
|
||||
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
|
||||
|
||||
fn main() {
|
||||
// Registers the command permissions (permissions/autogenerated) and, on
|
||||
// Android, hands the Gradle module under android/ to tauri-build, which
|
||||
// includes it in the app project (tauri.settings.gradle) — but only with
|
||||
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md.
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.android_path("android")
|
||||
.build();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-clear"
|
||||
description = "Enables the clear command without any pre-configured scope."
|
||||
commands.allow = ["clear"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-clear"
|
||||
description = "Denies the clear command without any pre-configured scope."
|
||||
commands.deny = ["clear"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-registerListener"
|
||||
description = "Enables the registerListener command without any pre-configured scope."
|
||||
commands.allow = ["registerListener"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-registerListener"
|
||||
description = "Denies the registerListener command without any pre-configured scope."
|
||||
commands.deny = ["registerListener"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-removeListener"
|
||||
description = "Enables the removeListener command without any pre-configured scope."
|
||||
commands.allow = ["removeListener"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-removeListener"
|
||||
description = "Denies the removeListener command without any pre-configured scope."
|
||||
commands.deny = ["removeListener"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-update"
|
||||
description = "Enables the update command without any pre-configured scope."
|
||||
commands.allow = ["update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-update"
|
||||
description = "Denies the update command without any pre-configured scope."
|
||||
commands.deny = ["update"]
|
||||
@@ -0,0 +1,126 @@
|
||||
## Default Permission
|
||||
|
||||
The downloads notice on the phone: `update` mirrors what Save is fetching,
|
||||
`clear` takes it down, and the two listener commands are what lets the page
|
||||
hear the notice's buttons (`addPluginListener`).
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
- `allow-update`
|
||||
- `allow-clear`
|
||||
- `allow-registerListener`
|
||||
- `allow-removeListener`
|
||||
|
||||
## Permission Table
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:allow-clear`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the clear command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:deny-clear`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the clear command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:allow-registerListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the registerListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:deny-registerListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the registerListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:allow-removeListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the removeListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:deny-removeListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the removeListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:allow-update`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the update command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`transfer-notice:deny-update`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the update command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,9 @@
|
||||
"$schema" = "schemas/schema.json"
|
||||
|
||||
[default]
|
||||
description = """
|
||||
The downloads notice on the phone: `update` mirrors what Save is fetching,
|
||||
`clear` takes it down, and the two listener commands are what lets the page
|
||||
hear the notice's buttons (`addPluginListener`).
|
||||
"""
|
||||
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PermissionFile",
|
||||
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default": {
|
||||
"description": "The default permission set for the plugin",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultPermission"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"set": {
|
||||
"description": "A list of permissions sets defined",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionSet"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"description": "A list of inlined permissions",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"DefaultPermission": {
|
||||
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"description": "A set of direct permissions grouped together under a new name.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does.",
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"description": "Allowed or denied commands when using this permission.",
|
||||
"default": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Commands"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"description": "Allowed or denied scoped when using this permission.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Scopes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands": {
|
||||
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Allowed command.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Denied command, which takes priority.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scopes": {
|
||||
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Value": {
|
||||
"description": "All supported ACL values.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents a null JSON value.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`bool`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Represents a valid ACL [`Number`].",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`String`].",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Represents a list of other [`Value`]s.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Number": {
|
||||
"description": "A valid ACL number.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents an [`i64`].",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`f64`].",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Target": {
|
||||
"description": "Platform target.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionKind": {
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Enables the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-clear",
|
||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-clear",
|
||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the registerListener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-registerListener",
|
||||
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the registerListener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-registerListener",
|
||||
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the removeListener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-removeListener",
|
||||
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the removeListener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-removeListener",
|
||||
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update",
|
||||
"markdownDescription": "Enables the update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update",
|
||||
"markdownDescription": "Denies the update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The downloads notice on the phone.
|
||||
//!
|
||||
//! The Transfers screen only exists inside the app: the desktop says what a
|
||||
//! Save is doing with its title-bar ring, the phone with a badge on the nav,
|
||||
//! and neither is visible once the app is in the background — which is
|
||||
//! exactly when a Save is left to run on its own. So a running Save says so
|
||||
//! where the phone says things, in the shade: how many files are left, the
|
||||
//! one it is on, a progress bar, Stop all and Open.
|
||||
//!
|
||||
//! Its own channel, low importance, so it can be silenced without silencing
|
||||
//! the player's card. Its own foreground service (`TransferService`,
|
||||
//! `dataSync`) rather than the media one, because a download is not
|
||||
//! playback: `MediaService` stops itself the moment the bar empties, and a
|
||||
//! `mediaPlayback` service with no session is a policy violation. That
|
||||
//! service is also what keeps the process alive for a Save with nothing
|
||||
//! playing — the way the first real Save died, four tracks in.
|
||||
//!
|
||||
//! Two commands, both fed by the phone UI (src/mobile/xfernote.svelte.js):
|
||||
//! `update` with what is still in flight, `clear` when the queue empties.
|
||||
//! The notice's buttons arrive as the plugin event `action`
|
||||
//! (`addPluginListener("transfer-notice", "action", …)`): `cancel` stops
|
||||
//! everything still going, `open` brings the app forward on Transfers.
|
||||
//!
|
||||
//! On the desktop the plugin is registered too, so the permission set
|
||||
//! resolves on every platform, but its commands answer "not here".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
};
|
||||
|
||||
/// What the notice says: how many files are still in flight, the one it is
|
||||
/// on, and how far the burst has come. `indeterminate` is a queue whose
|
||||
/// sizes no peer has stated yet — a bar with no end to walk towards.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransferNotice {
|
||||
#[serde(default)]
|
||||
pub count: u32,
|
||||
pub title: Option<String>,
|
||||
pub line: Option<String>,
|
||||
#[serde(default)]
|
||||
pub percent: i32,
|
||||
#[serde(default)]
|
||||
pub indeterminate: bool,
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
pub struct TransferNoticePlugin<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
||||
|
||||
#[tauri::command]
|
||||
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: TransferNotice) -> Result<(), String> {
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let notice = app.state::<TransferNoticePlugin<R>>();
|
||||
notice
|
||||
.0
|
||||
.run_mobile_plugin::<serde_json::Value>("update", state)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(mobile))]
|
||||
{
|
||||
let _ = (app, state);
|
||||
Err("the downloads notice is a phone feature".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let notice = app.state::<TransferNoticePlugin<R>>();
|
||||
notice
|
||||
.0
|
||||
.run_mobile_plugin::<serde_json::Value>("clear", ())
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(mobile))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("the downloads notice is a phone feature".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("transfer-notice")
|
||||
.invoke_handler(tauri::generate_handler![update, clear])
|
||||
.setup(|app, api| {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let handle = api.register_android_plugin(
|
||||
"com.soulstream.transfernotice",
|
||||
"TransferNoticePlugin",
|
||||
)?;
|
||||
app.manage(TransferNoticePlugin(handle));
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (app, api);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
Reference in new issue
Block a user