feat(immich): add Immich self-hosted import support

Adds an ImmichClient for Android to connect to a self-hosted Immich
server, fetch albums and assets, and import selected images locally.
API keys are encrypted with Android Keystore and stored securely.
The plugin now exposes connect, disconnect and import actions and
is documented for Immich import usage.

- Encrypted Immich API keys with Android Keystore
- Exposed connect, disconnect and import actions in the plugin
- Updated docs and metadata to reflect Immich import flow
This commit is contained in:
2026-08-21 20:29:26 +02:00
parent 6e43d52680
commit 8e5a53ade8
33 changed files with 1508 additions and 29 deletions
+8 -4
View File
@@ -4,12 +4,14 @@ Stand / Last updated: 21. August 2026
## Deutsch ## Deutsch
WallpaperFlow verarbeitet die vom Nutzer ausgewählten Bilder ausschließlich lokal auf dem Android-Gerät. Die Bilder werden in den privaten App-Speicher kopiert und nur dazu verwendet, Vorschaubilder anzuzeigen und das vom Nutzer gewählte Hintergrund- oder Sperrbildschirmbild zu setzen. WallpaperFlow verarbeitet die vom Nutzer ausgewählten Bilder grundsätzlich lokal auf dem Android-Gerät. Die Bilder werden in den privaten App-Speicher kopiert und nur dazu verwendet, Vorschaubilder anzuzeigen und das vom Nutzer gewählte Hintergrund- oder Sperrbildschirmbild zu setzen.
Optional kann der Nutzer die App mit einem eigenen, selbst gehosteten Immich-Server verbinden. In diesem Fall sendet WallpaperFlow den API-Key ausschließlich an die eingetragene Server-URL, um die Verbindung zu prüfen, Alben und Vorschaubilder abzurufen und ausdrücklich ausgewählte Bilder herunterzuladen. Der API-Key wird mit einem Schlüssel aus dem Android Keystore verschlüsselt gespeichert. Die Server-URL und der angezeigte Benutzername werden im privaten App-Speicher abgelegt. WallpaperFlow lädt keine Bilder zu Immich hoch und verändert keine Daten auf dem Server. Bei einer unverschlüsselten HTTP-URL weist die App auf die fehlende Transportverschlüsselung hin.
WallpaperFlow: WallpaperFlow:
- erhebt keine personenbezogenen Daten, - erhebt keine personenbezogenen Daten,
- überträgt keine Bilder oder Einstellungen an einen Server, - überträgt keine Bilder oder Einstellungen an fremde Server,
- gibt keine Daten an Dritte weiter, - gibt keine Daten an Dritte weiter,
- enthält keine Werbung, Analyse-, Tracking- oder Profiling-Dienste und - enthält keine Werbung, Analyse-, Tracking- oder Profiling-Dienste und
- verlangt keine Registrierung und kein Benutzerkonto. - verlangt keine Registrierung und kein Benutzerkonto.
@@ -23,12 +25,14 @@ https://git.cbsk-tech.de/Christoph/LockScreenWallpaper/issues
## English ## English
WallpaperFlow processes user-selected images exclusively on the local Android device. Images are copied into the app's private storage and are used only to display previews and to set the wallpaper or lock-screen image selected by the user. WallpaperFlow generally processes user-selected images on the local Android device. Images are copied into the app's private storage and are used only to display previews and to set the wallpaper or lock-screen image selected by the user.
Users may optionally connect the app to their own self-hosted Immich server. In this case, WallpaperFlow sends the API key only to the configured server URL to test the connection, retrieve albums and thumbnails, and download explicitly selected images. The API key is encrypted with a key held in the Android Keystore. The server URL and displayed user name are stored in the app's private storage. WallpaperFlow does not upload images to Immich or modify server data. The app warns users when an unencrypted HTTP URL is used.
WallpaperFlow: WallpaperFlow:
- does not collect personal data, - does not collect personal data,
- does not transmit images or settings to a server, - does not transmit images or settings to third-party servers,
- does not share data with third parties, - does not share data with third parties,
- contains no advertising, analytics, tracking or profiling services, and - contains no advertising, analytics, tracking or profiling services, and
- requires no registration or user account. - requires no registration or user account.
+2 -1
View File
@@ -12,6 +12,7 @@ WallpaperFlow ist eine freie Tauri-2-App für Android, die aus einer frei wählb
- automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden - automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden
- Neustart des Dienstes nach einem Geräte-Neustart, soweit die Android-Version Hintergrundstarts zulässt - Neustart des Dienstes nach einem Geräte-Neustart, soweit die Android-Version Hintergrundstarts zulässt
- lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht - lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht
- optionaler Import aus einem selbst gehosteten Immich-Server über einen eingeschränkten API-Key
- vollständig lokale Oberfläche in Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Polnisch, Portugiesisch, Japanisch, Koreanisch und vereinfachtem Chinesisch - vollständig lokale Oberfläche in Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Polnisch, Portugiesisch, Japanisch, Koreanisch und vereinfachtem Chinesisch
Android hält den Zeitgeber über einen Foreground Service aktiv. Solange ein Wechselintervall ausgewählt ist, zeigt das System deshalb eine dauerhafte, stille Benachrichtigung. Auf Geräten mit aggressivem Energiesparen muss LockScreenWallpaper gegebenenfalls von der Akku-Optimierung ausgenommen werden. Android hält den Zeitgeber über einen Foreground Service aktiv. Solange ein Wechselintervall ausgewählt ist, zeigt das System deshalb eine dauerhafte, stille Benachrichtigung. Auf Geräten mit aggressivem Energiesparen muss LockScreenWallpaper gegebenenfalls von der Akku-Optimierung ausgenommen werden.
@@ -52,7 +53,7 @@ Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er
## Datenschutz ## Datenschutz
WallpaperFlow verarbeitet die ausgewählten Bilder ausschließlich lokal auf dem Gerät. Die App enthält keine Werbung, kein Tracking und keine Analysedienste. Bilder werden weder hochgeladen noch an Dritte weitergegeben. WallpaperFlow verarbeitet importierte Bilder lokal auf dem Gerät. Die optionale Immich-Anbindung kommuniziert ausschließlich mit dem vom Nutzer eingetragenen, selbst gehosteten Server und lädt nur ausdrücklich ausgewählte Bilder herunter. Die App enthält keine Werbung, kein Tracking und keine Analysedienste. Bilder werden nicht hochgeladen oder an andere Server weitergegeben.
Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md). Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md).
@@ -1 +1 @@
Erste öffentliche Version von WallpaperFlow mit automatischem Bildwechsel, lokaler Galerie, Mehrfachauswahl, Intervallsteuerung und mehrsprachiger Oberfläche. Erste öffentliche Version mit automatischem Bildwechsel, lokaler Galerie, Mehrfachauswahl, Immich-Import, Intervallsteuerung und mehrsprachiger Oberfläche.
@@ -6,7 +6,8 @@ Funktionen:
* zufällige oder fortlaufende Reihenfolge * zufällige oder fortlaufende Reihenfolge
* nur den Sperrbildschirm oder das allgemeine Hintergrundbild ändern * nur den Sperrbildschirm oder das allgemeine Hintergrundbild ändern
* Bilder gemeinsam auswählen und löschen * Bilder gemeinsam auswählen und löschen
* Bilder aus einem eigenen Immich-Server auswählen und lokal importieren
* Wechsel nach einem Neustart automatisch fortsetzen * Wechsel nach einem Neustart automatisch fortsetzen
* Oberfläche in mehreren europäischen und asiatischen Sprachen * Oberfläche in mehreren europäischen und asiatischen Sprachen
Alle Bilder und Einstellungen bleiben lokal auf deinem Gerät. WallpaperFlow enthält keine Werbung, kein Tracking und keine Analysedienste. Importierte Bilder und Einstellungen bleiben lokal auf deinem Gerät. Die optionale Immich-Verbindung kommuniziert nur mit deinem eingetragenen Server. WallpaperFlow enthält keine Werbung, kein Tracking und keine Analysedienste.
@@ -1 +1 @@
First public release of WallpaperFlow with automatic rotation, a local gallery, multi-selection, configurable intervals and a multilingual interface. First public release with automatic rotation, a local gallery, multi-selection, Immich import, configurable intervals and a multilingual interface.
@@ -6,7 +6,8 @@ Features:
* shuffle or sequential order * shuffle or sequential order
* change only the lock screen or the general wallpaper * change only the lock screen or the general wallpaper
* select and delete several images at once * select and delete several images at once
* select images from your own Immich server and import them locally
* resume rotation after a device restart * resume rotation after a device restart
* interface in multiple European and Asian languages * interface in multiple European and Asian languages
All images and settings remain locally on your device. WallpaperFlow contains no advertising, tracking or analytics services. Imported images and settings remain locally on your device. The optional Immich connection communicates only with the server you configure. WallpaperFlow contains no advertising, tracking or analytics services.
@@ -0,0 +1,291 @@
package de.wechselbild.wallpaper
import android.content.Context
import android.graphics.BitmapFactory
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import org.json.JSONArray
import org.json.JSONObject
object ImmichClient {
private const val PREFS = "wechselbild"
private const val KEY_SERVER_URL = "immich_server_url"
private const val KEY_USER_NAME = "immich_user_name"
private const val KEY_API_KEY_DATA = "immich_api_key_data"
private const val KEY_API_KEY_IV = "immich_api_key_iv"
private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
private val thumbnailPool = Executors.newFixedThreadPool(4)
private data class Credentials(val serverUrl: String, val apiKey: String)
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun normalizeServerUrl(value: String): String {
val raw = value.trim().trimEnd('/')
require(raw.isNotBlank()) { "Bitte gib die URL deines Immich-Servers ein." }
val uri = runCatching { URI(raw) }.getOrNull()
?: throw IllegalArgumentException("Die Immich-URL ist ungültig.")
require(uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) {
"Die Immich-URL muss mit https:// oder http:// beginnen."
}
require(!uri.host.isNullOrBlank()) { "Die Immich-URL enthält keinen gültigen Hostnamen." }
require(uri.userInfo == null && uri.query == null && uri.fragment == null) { "Die Immich-URL ist ungültig." }
val basePath = (uri.path ?: "").trimEnd('/')
val apiPath = if (basePath.endsWith("/api")) basePath else "$basePath/api"
return URI(uri.scheme.lowercase(), null, uri.host, uri.port, apiPath, null, null).toString().trimEnd('/')
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
generateKey()
}
}
private fun encryptApiKey(context: Context, value: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, secretKey()) }
val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
prefs(context).edit()
.putString(KEY_API_KEY_DATA, Base64.encodeToString(encrypted, Base64.NO_WRAP))
.putString(KEY_API_KEY_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
private fun decryptApiKey(context: Context): String? = runCatching {
val preferences = prefs(context)
val encrypted = Base64.decode(preferences.getString(KEY_API_KEY_DATA, null), Base64.NO_WRAP)
val iv = Base64.decode(preferences.getString(KEY_API_KEY_IV, null), Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
}
String(cipher.doFinal(encrypted), StandardCharsets.UTF_8)
}.getOrNull()
private fun credentials(context: Context): Credentials {
val serverUrl = prefs(context).getString(KEY_SERVER_URL, null).orEmpty()
val apiKey = decryptApiKey(context).orEmpty()
check(serverUrl.isNotBlank() && apiKey.isNotBlank()) { "Immich ist noch nicht verbunden." }
return Credentials(serverUrl, apiKey)
}
private fun connectionObject(context: Context): JSObject {
val preferences = prefs(context)
val serverUrl = preferences.getString(KEY_SERVER_URL, "").orEmpty()
return JSObject().apply {
put("configured", serverUrl.isNotBlank() && !decryptApiKey(context).isNullOrBlank())
put("serverUrl", serverUrl)
put("userName", preferences.getString(KEY_USER_NAME, "").orEmpty())
}
}
fun connection(context: Context) = connectionObject(context)
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
val normalized = normalizeServerUrl(serverUrl)
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
val user = requestJson(normalized, apiKey.trim(), "/users/me")
val userName = user.optString("name").ifBlank { user.optString("email") }.ifBlank { "Immich" }
encryptApiKey(context, apiKey.trim())
prefs(context).edit().putString(KEY_SERVER_URL, normalized).putString(KEY_USER_NAME, userName).apply()
return connectionObject(context)
}
fun disconnect(context: Context): JSObject {
prefs(context).edit()
.remove(KEY_SERVER_URL)
.remove(KEY_USER_NAME)
.remove(KEY_API_KEY_DATA)
.remove(KEY_API_KEY_IV)
.apply()
return connectionObject(context)
}
private fun openConnection(
serverUrl: String,
apiKey: String,
path: String,
method: String = "GET",
body: String? = null,
): HttpURLConnection {
val connection = URI(serverUrl + path).toURL().openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.instanceFollowRedirects = true
connection.setRequestProperty("Accept", "application/json, image/*, application/octet-stream")
connection.setRequestProperty("x-api-key", apiKey)
if (body != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) }
}
return connection
}
private fun errorMessage(connection: HttpURLConnection): String {
val detail = runCatching {
connection.errorStream?.bufferedReader()?.use { it.readText().take(600) }.orEmpty()
}.getOrDefault("")
val message = runCatching { JSONObject(detail).optString("message") }.getOrDefault("")
return when (connection.responseCode) {
401, 403 -> "Immich hat den API-Key abgelehnt. Prüfe die Berechtigungen des Schlüssels."
404 -> "Die Immich-API wurde unter dieser URL nicht gefunden."
else -> message.ifBlank { "Immich antwortet mit HTTP ${connection.responseCode}." }
}
}
private fun requestJson(serverUrl: String, apiKey: String, path: String, method: String = "GET", body: String? = null): JSONObject {
val connection = openConnection(serverUrl, apiKey, path, method, body)
try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} finally { connection.disconnect() }
}
private fun requestArray(serverUrl: String, apiKey: String, path: String): JSONArray {
val connection = openConnection(serverUrl, apiKey, path)
try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
} finally { connection.disconnect() }
}
private fun requestBytes(serverUrl: String, apiKey: String, path: String): Pair<ByteArray, String> {
val connection = openConnection(serverUrl, apiKey, path)
try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
val bytes = connection.inputStream.use { input -> ByteArrayOutputStream().use { output -> input.copyTo(output); output.toByteArray() } }
return bytes to connection.contentType.orEmpty().substringBefore(';')
} finally { connection.disconnect() }
}
private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File): String {
val connection = openConnection(serverUrl, apiKey, path)
try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
connection.inputStream.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
return connection.contentType.orEmpty().substringBefore(';')
} catch (error: Exception) {
target.delete()
throw error
} finally { connection.disconnect() }
}
private fun thumbnailDataUrl(credentials: Credentials, assetId: String): String = runCatching {
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
val (bytes, contentType) = requestBytes(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=thumbnail")
val mime = if (contentType.startsWith("image/")) contentType else "image/jpeg"
"data:$mime;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
}.getOrDefault("")
fun albums(context: Context): JSObject {
val credentials = credentials(context)
val response = requestArray(credentials.serverUrl, credentials.apiKey, "/albums")
val albums = JSArray()
(0 until response.length()).map { response.getJSONObject(it) }
.sortedBy { it.optString("albumName").lowercase() }
.forEach { album ->
albums.put(JSObject().apply {
put("id", album.getString("id"))
put("name", album.optString("albumName", "Album"))
put("assetCount", album.optInt("assetCount", 0))
put("thumbnailUrl", "")
})
}
return JSObject().apply { put("albums", albums) }
}
fun assets(context: Context, albumId: String?, page: Int, size: Int): JSObject {
val credentials = credentials(context)
val safePage = page.coerceAtLeast(1)
val safeSize = size.coerceIn(1, 60)
val body = JSONObject().apply {
put("type", "IMAGE")
put("page", safePage)
put("size", safeSize)
put("order", "desc")
if (!albumId.isNullOrBlank()) put("albumIds", JSONArray().put(albumId))
}
val response = requestJson(credentials.serverUrl, credentials.apiKey, "/search/metadata", "POST", body.toString())
.getJSONObject("assets")
val jsonItems = response.getJSONArray("items")
val raw = (0 until jsonItems.length()).map { jsonItems.getJSONObject(it) }
val thumbnails = thumbnailPool.invokeAll(raw.map { asset -> Callable { thumbnailDataUrl(credentials, asset.getString("id")) } })
val items = JSArray()
raw.forEachIndexed { index, asset ->
items.put(JSObject().apply {
put("id", asset.getString("id"))
put("fileName", asset.optString("originalFileName", "Immich image"))
put("thumbnailUrl", runCatching { thumbnails[index].get() }.getOrDefault(""))
put("takenAt", asset.optString("localDateTime", asset.optString("fileCreatedAt", "")))
})
}
return JSObject().apply {
put("items", items)
put("page", safePage)
put("hasMore", !response.isNull("nextPage") && response.optString("nextPage").isNotBlank())
}
}
private fun extension(contentType: String) = when (contentType.lowercase()) {
"image/png" -> "png"
"image/webp" -> "webp"
"image/heic", "image/heif" -> "heic"
else -> "jpg"
}
private fun importOne(context: Context, credentials: Credentials, assetId: String) {
require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." }
if (WallpaperStore.files(context).any { it.name.startsWith("immich-$assetId.") }) return
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
val directory = WallpaperStore.directory(context)
val temporary = File(directory, ".immich-$assetId.download")
var contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/original", temporary)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
temporary.delete()
contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat kein unterstütztes Bildformat geliefert." }
}
val target = File(directory, "immich-$assetId.${extension(contentType)}")
if (!temporary.renameTo(target)) {
temporary.copyTo(target, overwrite = true)
temporary.delete()
}
}
fun importAssets(context: Context, ids: List<String>): JSObject {
require(ids.isNotEmpty()) { "Wähle mindestens ein Immich-Bild aus." }
require(ids.size <= 100) { "Bitte importiere höchstens 100 Bilder auf einmal." }
val credentials = credentials(context)
ids.distinct().forEach { importOne(context, credentials, it) }
return WallpaperStore.state(context)
}
}
@@ -40,6 +40,15 @@ class ImageCropArgs {
var positionY: Double = 0.5 var positionY: Double = 0.5
} }
@InvokeArg
class ImmichConnectArgs { lateinit var serverUrl: String; lateinit var apiKey: String }
@InvokeArg
class ImmichAssetsArgs { var albumId: String? = null; var page: Int = 1; var size: Int = 30 }
@InvokeArg
class ImmichImportArgs { var ids: Array<String> = emptyArray() }
@TauriPlugin @TauriPlugin
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) { class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor() private val io = Executors.newSingleThreadExecutor()
@@ -136,4 +145,38 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@Command fun nextWallpaper(invoke: Invoke) = io.execute { @Command fun nextWallpaper(invoke: Invoke) = io.execute {
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus") if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus")
} }
@Command fun getImmichConnection(invoke: Invoke) = io.execute {
invoke.resolve(ImmichClient.connection(activity))
}
@Command fun connectImmich(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
invoke.resolve(ImmichClient.connect(activity, args.serverUrl, args.apiKey))
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
}
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
invoke.resolve(ImmichClient.disconnect(activity))
}
@Command fun getImmichAlbums(invoke: Invoke) = io.execute {
try { invoke.resolve(ImmichClient.albums(activity)) }
catch (error: Exception) { invoke.reject(error.message ?: "Immich-Alben konnten nicht geladen werden") }
}
@Command fun getImmichAssets(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImmichAssetsArgs::class.java)
invoke.resolve(ImmichClient.assets(activity, args.albumId, args.page, args.size))
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Bilder konnten nicht geladen werden") }
}
@Command fun importImmichAssets(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImmichImportArgs::class.java)
invoke.resolve(ImmichClient.importAssets(activity, args.ids.toList()))
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Bilder konnten nicht importiert werden") }
}
} }
+6
View File
@@ -9,6 +9,12 @@ const COMMANDS: &[&str] = &[
"set_setting", "set_setting",
"set_interval", "set_interval",
"next_wallpaper", "next_wallpaper",
"get_immich_connection",
"connect_immich",
"disconnect_immich",
"get_immich_albums",
"get_immich_assets",
"import_immich_assets",
]; ];
fn main() { fn main() {
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-connect-immich"
description = "Enables the connect_immich command without any pre-configured scope."
commands.allow = ["connect_immich"]
[[permission]]
identifier = "deny-connect-immich"
description = "Denies the connect_immich command without any pre-configured scope."
commands.deny = ["connect_immich"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-disconnect-immich"
description = "Enables the disconnect_immich command without any pre-configured scope."
commands.allow = ["disconnect_immich"]
[[permission]]
identifier = "deny-disconnect-immich"
description = "Denies the disconnect_immich command without any pre-configured scope."
commands.deny = ["disconnect_immich"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-immich-albums"
description = "Enables the get_immich_albums command without any pre-configured scope."
commands.allow = ["get_immich_albums"]
[[permission]]
identifier = "deny-get-immich-albums"
description = "Denies the get_immich_albums command without any pre-configured scope."
commands.deny = ["get_immich_albums"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-immich-assets"
description = "Enables the get_immich_assets command without any pre-configured scope."
commands.allow = ["get_immich_assets"]
[[permission]]
identifier = "deny-get-immich-assets"
description = "Denies the get_immich_assets command without any pre-configured scope."
commands.deny = ["get_immich_assets"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-immich-connection"
description = "Enables the get_immich_connection command without any pre-configured scope."
commands.allow = ["get_immich_connection"]
[[permission]]
identifier = "deny-get-immich-connection"
description = "Denies the get_immich_connection command without any pre-configured scope."
commands.deny = ["get_immich_connection"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-import-immich-assets"
description = "Enables the import_immich_assets command without any pre-configured scope."
commands.allow = ["import_immich_assets"]
[[permission]]
identifier = "deny-import-immich-assets"
description = "Denies the import_immich_assets command without any pre-configured scope."
commands.deny = ["import_immich_assets"]
@@ -14,6 +14,12 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
- `allow-set-setting` - `allow-set-setting`
- `allow-set-interval` - `allow-set-interval`
- `allow-next-wallpaper` - `allow-next-wallpaper`
- `allow-get-immich-connection`
- `allow-connect-immich`
- `allow-disconnect-immich`
- `allow-get-immich-albums`
- `allow-get-immich-assets`
- `allow-import-immich-assets`
## Permission Table ## Permission Table
@@ -24,6 +30,32 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
</tr> </tr>
<tr>
<td>
`wallpaper:allow-connect-immich`
</td>
<td>
Enables the connect_immich command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-connect-immich`
</td>
<td>
Denies the connect_immich command without any pre-configured scope.
</td>
</tr>
<tr> <tr>
<td> <td>
@@ -79,6 +111,32 @@ Denies the delete_images command without any pre-configured scope.
<tr> <tr>
<td> <td>
`wallpaper:allow-disconnect-immich`
</td>
<td>
Enables the disconnect_immich command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-disconnect-immich`
</td>
<td>
Denies the disconnect_immich command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-get-gallery` `wallpaper:allow-get-gallery`
</td> </td>
@@ -131,6 +189,84 @@ Denies the get_image_ids command without any pre-configured scope.
<tr> <tr>
<td> <td>
`wallpaper:allow-get-immich-albums`
</td>
<td>
Enables the get_immich_albums command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-get-immich-albums`
</td>
<td>
Denies the get_immich_albums command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-get-immich-assets`
</td>
<td>
Enables the get_immich_assets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-get-immich-assets`
</td>
<td>
Denies the get_immich_assets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-get-immich-connection`
</td>
<td>
Enables the get_immich_connection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-get-immich-connection`
</td>
<td>
Denies the get_immich_connection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-get-state` `wallpaper:allow-get-state`
</td> </td>
@@ -157,6 +293,32 @@ Denies the get_state command without any pre-configured scope.
<tr> <tr>
<td> <td>
`wallpaper:allow-import-immich-assets`
</td>
<td>
Enables the import_immich_assets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-import-immich-assets`
</td>
<td>
Denies the import_immich_assets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-next-wallpaper` `wallpaper:allow-next-wallpaper`
</td> </td>
+1 -1
View File
@@ -1,3 +1,3 @@
[default] [default]
description = "Allow the LockScreenWallpaper app to manage its wallpaper collection" description = "Allow the LockScreenWallpaper app to manage its wallpaper collection"
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-get-image-ids", "allow-delete-images", "allow-set-image-crop", "allow-set-setting", "allow-set-interval", "allow-next-wallpaper"] permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-get-image-ids", "allow-delete-images", "allow-set-image-crop", "allow-set-setting", "allow-set-interval", "allow-next-wallpaper", "allow-get-immich-connection", "allow-connect-immich", "allow-disconnect-immich", "allow-get-immich-albums", "allow-get-immich-assets", "allow-import-immich-assets"]
+74 -2
View File
@@ -294,6 +294,18 @@
"PermissionKind": { "PermissionKind": {
"type": "string", "type": "string",
"oneOf": [ "oneOf": [
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "allow-connect-immich",
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
},
{
"description": "Denies the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "deny-connect-immich",
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the delete_image command without any pre-configured scope.", "description": "Enables the delete_image command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -318,6 +330,18 @@
"const": "deny-delete-images", "const": "deny-delete-images",
"markdownDescription": "Denies the delete_images command without any pre-configured scope." "markdownDescription": "Denies the delete_images command without any pre-configured scope."
}, },
{
"description": "Enables the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "allow-disconnect-immich",
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
},
{
"description": "Denies the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "deny-disconnect-immich",
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the get_gallery command without any pre-configured scope.", "description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -342,6 +366,42 @@
"const": "deny-get-image-ids", "const": "deny-get-image-ids",
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope." "markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Enables the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "allow-get-immich-albums",
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
},
{
"description": "Denies the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "deny-get-immich-albums",
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
},
{
"description": "Enables the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "allow-get-immich-assets",
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "deny-get-immich-assets",
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
},
{
"description": "Enables the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-get-immich-connection",
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
},
{
"description": "Denies the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-get-immich-connection",
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Enables the get_state command without any pre-configured scope.", "description": "Enables the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -354,6 +414,18 @@
"const": "deny-get-state", "const": "deny-get-state",
"markdownDescription": "Denies the get_state command without any pre-configured scope." "markdownDescription": "Denies the get_state command without any pre-configured scope."
}, },
{
"description": "Enables the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "allow-import-immich-assets",
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "deny-import-immich-assets",
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Enables the next_wallpaper command without any pre-configured scope.", "description": "Enables the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -415,10 +487,10 @@
"markdownDescription": "Denies the set_setting command without any pre-configured scope." "markdownDescription": "Denies the set_setting command without any pre-configured scope."
}, },
{ {
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`", "description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`",
"type": "string", "type": "string",
"const": "default", "const": "default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`" "markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`"
} }
] ]
} }
+42
View File
@@ -69,3 +69,45 @@ pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: usize)
pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> { pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
app.wallpaper().next_wallpaper() app.wallpaper().next_wallpaper()
} }
#[command]
pub(crate) async fn get_immich_connection<R: Runtime>(app: AppHandle<R>) -> Result<ImmichConnection> {
app.wallpaper().get_immich_connection()
}
#[command]
pub(crate) async fn connect_immich<R: Runtime>(
app: AppHandle<R>,
server_url: String,
api_key: String,
) -> Result<ImmichConnection> {
app.wallpaper().connect_immich(ImmichConnectRequest { server_url, api_key })
}
#[command]
pub(crate) async fn disconnect_immich<R: Runtime>(app: AppHandle<R>) -> Result<ImmichConnection> {
app.wallpaper().disconnect_immich()
}
#[command]
pub(crate) async fn get_immich_albums<R: Runtime>(app: AppHandle<R>) -> Result<ImmichAlbumsResponse> {
app.wallpaper().get_immich_albums()
}
#[command]
pub(crate) async fn get_immich_assets<R: Runtime>(
app: AppHandle<R>,
album_id: Option<String>,
page: usize,
size: usize,
) -> Result<ImmichAssetsPage> {
app.wallpaper().get_immich_assets(ImmichAssetsRequest { album_id, page, size })
}
#[command]
pub(crate) async fn import_immich_assets<R: Runtime>(
app: AppHandle<R>,
ids: Vec<String>,
) -> Result<WallpaperState> {
app.wallpaper().import_immich_assets(ImmichImportRequest { ids })
}
+43
View File
@@ -100,4 +100,47 @@ impl<R: Runtime> Wallpaper<R> {
state.current_index = 1; state.current_index = 1;
Ok(state) Ok(state)
} }
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
Ok(ImmichConnection::default())
}
pub fn connect_immich(&self, payload: ImmichConnectRequest) -> crate::Result<ImmichConnection> {
Ok(ImmichConnection {
configured: true,
server_url: payload.server_url,
user_name: "Demo User".into(),
})
}
pub fn disconnect_immich(&self) -> crate::Result<ImmichConnection> {
Ok(ImmichConnection::default())
}
pub fn get_immich_albums(&self) -> crate::Result<ImmichAlbumsResponse> {
Ok(ImmichAlbumsResponse {
albums: vec![ImmichAlbum {
id: "demo-album".into(),
name: "Nature".into(),
asset_count: 3,
thumbnail_url: "/wallpapers/alpine.png".into(),
}],
})
}
pub fn get_immich_assets(&self, payload: ImmichAssetsRequest) -> crate::Result<ImmichAssetsPage> {
let urls = Self::demo().image_urls;
Ok(ImmichAssetsPage {
items: urls
.into_iter()
.enumerate()
.map(|(index, thumbnail_url)| ImmichAsset {
id: format!("immich-demo-{index}"),
file_name: format!("wallpaper-{}.jpg", index + 1),
thumbnail_url,
taken_at: "2026-08-21T12:00:00Z".into(),
})
.collect(),
page: payload.page,
has_more: false,
})
}
pub fn import_immich_assets(&self, _payload: ImmichImportRequest) -> crate::Result<WallpaperState> {
Ok(Self::demo())
}
} }
+7 -1
View File
@@ -45,7 +45,13 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_image_crop, commands::set_image_crop,
commands::set_setting, commands::set_setting,
commands::set_interval, commands::set_interval,
commands::next_wallpaper commands::next_wallpaper,
commands::get_immich_connection,
commands::connect_immich,
commands::disconnect_immich,
commands::get_immich_albums,
commands::get_immich_assets,
commands::import_immich_assets
]) ])
.setup(|app, api| { .setup(|app, api| {
#[cfg(mobile)] #[cfg(mobile)]
+18
View File
@@ -70,4 +70,22 @@ impl<R: Runtime> Wallpaper<R> {
.run_mobile_plugin("nextWallpaper", ()) .run_mobile_plugin("nextWallpaper", ())
.map_err(Into::into) .map_err(Into::into)
} }
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
self.0.run_mobile_plugin("getImmichConnection", ()).map_err(Into::into)
}
pub fn connect_immich(&self, payload: ImmichConnectRequest) -> crate::Result<ImmichConnection> {
self.0.run_mobile_plugin("connectImmich", payload).map_err(Into::into)
}
pub fn disconnect_immich(&self) -> crate::Result<ImmichConnection> {
self.0.run_mobile_plugin("disconnectImmich", ()).map_err(Into::into)
}
pub fn get_immich_albums(&self) -> crate::Result<ImmichAlbumsResponse> {
self.0.run_mobile_plugin("getImmichAlbums", ()).map_err(Into::into)
}
pub fn get_immich_assets(&self, payload: ImmichAssetsRequest) -> crate::Result<ImmichAssetsPage> {
self.0.run_mobile_plugin("getImmichAssets", payload).map_err(Into::into)
}
pub fn import_immich_assets(&self, payload: ImmichImportRequest) -> crate::Result<WallpaperState> {
self.0.run_mobile_plugin("importImmichAssets", payload).map_err(Into::into)
}
} }
+61
View File
@@ -78,3 +78,64 @@ pub struct GalleryPage {
pub total: usize, pub total: usize,
pub items: Vec<GalleryImage>, pub items: Vec<GalleryImage>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichConnectRequest {
pub server_url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichConnection {
pub configured: bool,
pub server_url: String,
pub user_name: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichAlbum {
pub id: String,
pub name: String,
pub asset_count: usize,
pub thumbnail_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichAlbumsResponse {
pub albums: Vec<ImmichAlbum>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichAssetsRequest {
pub album_id: Option<String>,
pub page: usize,
pub size: usize,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichAsset {
pub id: String,
pub file_name: String,
pub thumbnail_url: String,
pub taken_at: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichAssetsPage {
pub items: Vec<ImmichAsset>,
pub page: usize,
pub has_more: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImmichImportRequest {
pub ids: Vec<String>,
}
+3 -1
View File
@@ -17,7 +17,9 @@ android {
compileSdk = 36 compileSdk = 36
namespace = "de.wechselbild.app" namespace = "de.wechselbild.app"
defaultConfig { defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false" // Immich is frequently hosted on a private LAN without TLS. The app only
// connects to the server URL explicitly entered by the user.
manifestPlaceholders["usesCleartextTraffic"] = "true"
applicationId = "de.wechselbild.app" applicationId = "de.wechselbild.app"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
File diff suppressed because one or more lines are too long
+74 -2
View File
@@ -2193,10 +2193,16 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`", "description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`",
"type": "string", "type": "string",
"const": "wallpaper:default", "const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`" "markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`"
},
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-connect-immich",
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
}, },
{ {
"description": "Enables the delete_image command without any pre-configured scope.", "description": "Enables the delete_image command without any pre-configured scope.",
@@ -2210,6 +2216,12 @@
"const": "wallpaper:allow-delete-images", "const": "wallpaper:allow-delete-images",
"markdownDescription": "Enables the delete_images command without any pre-configured scope." "markdownDescription": "Enables the delete_images command without any pre-configured scope."
}, },
{
"description": "Enables the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-disconnect-immich",
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the get_gallery command without any pre-configured scope.", "description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2222,12 +2234,36 @@
"const": "wallpaper:allow-get-image-ids", "const": "wallpaper:allow-get-image-ids",
"markdownDescription": "Enables the get_image_ids command without any pre-configured scope." "markdownDescription": "Enables the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Enables the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-albums",
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
},
{
"description": "Enables the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-assets",
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
},
{
"description": "Enables the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-connection",
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Enables the get_state command without any pre-configured scope.", "description": "Enables the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:allow-get-state", "const": "wallpaper:allow-get-state",
"markdownDescription": "Enables the get_state command without any pre-configured scope." "markdownDescription": "Enables the get_state command without any pre-configured scope."
}, },
{
"description": "Enables the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-import-immich-assets",
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Enables the next_wallpaper command without any pre-configured scope.", "description": "Enables the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2258,6 +2294,12 @@
"const": "wallpaper:allow-set-setting", "const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope." "markdownDescription": "Enables the set_setting command without any pre-configured scope."
}, },
{
"description": "Denies the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-connect-immich",
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the delete_image command without any pre-configured scope.", "description": "Denies the delete_image command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2270,6 +2312,12 @@
"const": "wallpaper:deny-delete-images", "const": "wallpaper:deny-delete-images",
"markdownDescription": "Denies the delete_images command without any pre-configured scope." "markdownDescription": "Denies the delete_images command without any pre-configured scope."
}, },
{
"description": "Denies the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-disconnect-immich",
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the get_gallery command without any pre-configured scope.", "description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2282,12 +2330,36 @@
"const": "wallpaper:deny-get-image-ids", "const": "wallpaper:deny-get-image-ids",
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope." "markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Denies the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-albums",
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
},
{
"description": "Denies the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-assets",
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-connection",
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Denies the get_state command without any pre-configured scope.", "description": "Denies the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:deny-get-state", "const": "wallpaper:deny-get-state",
"markdownDescription": "Denies the get_state command without any pre-configured scope." "markdownDescription": "Denies the get_state command without any pre-configured scope."
}, },
{
"description": "Denies the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-import-immich-assets",
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Denies the next_wallpaper command without any pre-configured scope.", "description": "Denies the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
+74 -2
View File
@@ -2193,10 +2193,16 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`", "description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`",
"type": "string", "type": "string",
"const": "wallpaper:default", "const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`" "markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`"
},
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-connect-immich",
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
}, },
{ {
"description": "Enables the delete_image command without any pre-configured scope.", "description": "Enables the delete_image command without any pre-configured scope.",
@@ -2210,6 +2216,12 @@
"const": "wallpaper:allow-delete-images", "const": "wallpaper:allow-delete-images",
"markdownDescription": "Enables the delete_images command without any pre-configured scope." "markdownDescription": "Enables the delete_images command without any pre-configured scope."
}, },
{
"description": "Enables the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-disconnect-immich",
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the get_gallery command without any pre-configured scope.", "description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2222,12 +2234,36 @@
"const": "wallpaper:allow-get-image-ids", "const": "wallpaper:allow-get-image-ids",
"markdownDescription": "Enables the get_image_ids command without any pre-configured scope." "markdownDescription": "Enables the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Enables the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-albums",
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
},
{
"description": "Enables the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-assets",
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
},
{
"description": "Enables the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-connection",
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Enables the get_state command without any pre-configured scope.", "description": "Enables the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:allow-get-state", "const": "wallpaper:allow-get-state",
"markdownDescription": "Enables the get_state command without any pre-configured scope." "markdownDescription": "Enables the get_state command without any pre-configured scope."
}, },
{
"description": "Enables the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-import-immich-assets",
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Enables the next_wallpaper command without any pre-configured scope.", "description": "Enables the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2258,6 +2294,12 @@
"const": "wallpaper:allow-set-setting", "const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope." "markdownDescription": "Enables the set_setting command without any pre-configured scope."
}, },
{
"description": "Denies the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-connect-immich",
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the delete_image command without any pre-configured scope.", "description": "Denies the delete_image command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2270,6 +2312,12 @@
"const": "wallpaper:deny-delete-images", "const": "wallpaper:deny-delete-images",
"markdownDescription": "Denies the delete_images command without any pre-configured scope." "markdownDescription": "Denies the delete_images command without any pre-configured scope."
}, },
{
"description": "Denies the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-disconnect-immich",
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the get_gallery command without any pre-configured scope.", "description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2282,12 +2330,36 @@
"const": "wallpaper:deny-get-image-ids", "const": "wallpaper:deny-get-image-ids",
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope." "markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Denies the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-albums",
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
},
{
"description": "Denies the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-assets",
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-connection",
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Denies the get_state command without any pre-configured scope.", "description": "Denies the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:deny-get-state", "const": "wallpaper:deny-get-state",
"markdownDescription": "Denies the get_state command without any pre-configured scope." "markdownDescription": "Denies the get_state command without any pre-configured scope."
}, },
{
"description": "Denies the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-import-immich-assets",
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Denies the next_wallpaper command without any pre-configured scope.", "description": "Denies the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
+74 -2
View File
@@ -2193,10 +2193,16 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`", "description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`",
"type": "string", "type": "string",
"const": "wallpaper:default", "const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`" "markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`"
},
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-connect-immich",
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
}, },
{ {
"description": "Enables the delete_image command without any pre-configured scope.", "description": "Enables the delete_image command without any pre-configured scope.",
@@ -2210,6 +2216,12 @@
"const": "wallpaper:allow-delete-images", "const": "wallpaper:allow-delete-images",
"markdownDescription": "Enables the delete_images command without any pre-configured scope." "markdownDescription": "Enables the delete_images command without any pre-configured scope."
}, },
{
"description": "Enables the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-disconnect-immich",
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the get_gallery command without any pre-configured scope.", "description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2222,12 +2234,36 @@
"const": "wallpaper:allow-get-image-ids", "const": "wallpaper:allow-get-image-ids",
"markdownDescription": "Enables the get_image_ids command without any pre-configured scope." "markdownDescription": "Enables the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Enables the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-albums",
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
},
{
"description": "Enables the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-assets",
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
},
{
"description": "Enables the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-connection",
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Enables the get_state command without any pre-configured scope.", "description": "Enables the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:allow-get-state", "const": "wallpaper:allow-get-state",
"markdownDescription": "Enables the get_state command without any pre-configured scope." "markdownDescription": "Enables the get_state command without any pre-configured scope."
}, },
{
"description": "Enables the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-import-immich-assets",
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Enables the next_wallpaper command without any pre-configured scope.", "description": "Enables the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2258,6 +2294,12 @@
"const": "wallpaper:allow-set-setting", "const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope." "markdownDescription": "Enables the set_setting command without any pre-configured scope."
}, },
{
"description": "Denies the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-connect-immich",
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the delete_image command without any pre-configured scope.", "description": "Denies the delete_image command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2270,6 +2312,12 @@
"const": "wallpaper:deny-delete-images", "const": "wallpaper:deny-delete-images",
"markdownDescription": "Denies the delete_images command without any pre-configured scope." "markdownDescription": "Denies the delete_images command without any pre-configured scope."
}, },
{
"description": "Denies the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-disconnect-immich",
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the get_gallery command without any pre-configured scope.", "description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2282,12 +2330,36 @@
"const": "wallpaper:deny-get-image-ids", "const": "wallpaper:deny-get-image-ids",
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope." "markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Denies the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-albums",
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
},
{
"description": "Denies the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-assets",
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-connection",
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Denies the get_state command without any pre-configured scope.", "description": "Denies the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:deny-get-state", "const": "wallpaper:deny-get-state",
"markdownDescription": "Denies the get_state command without any pre-configured scope." "markdownDescription": "Denies the get_state command without any pre-configured scope."
}, },
{
"description": "Denies the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-import-immich-assets",
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Denies the next_wallpaper command without any pre-configured scope.", "description": "Denies the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
+74 -2
View File
@@ -2193,10 +2193,16 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`", "description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`",
"type": "string", "type": "string",
"const": "wallpaper:default", "const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`" "markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`"
},
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-connect-immich",
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
}, },
{ {
"description": "Enables the delete_image command without any pre-configured scope.", "description": "Enables the delete_image command without any pre-configured scope.",
@@ -2210,6 +2216,12 @@
"const": "wallpaper:allow-delete-images", "const": "wallpaper:allow-delete-images",
"markdownDescription": "Enables the delete_images command without any pre-configured scope." "markdownDescription": "Enables the delete_images command without any pre-configured scope."
}, },
{
"description": "Enables the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-disconnect-immich",
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Enables the get_gallery command without any pre-configured scope.", "description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2222,12 +2234,36 @@
"const": "wallpaper:allow-get-image-ids", "const": "wallpaper:allow-get-image-ids",
"markdownDescription": "Enables the get_image_ids command without any pre-configured scope." "markdownDescription": "Enables the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Enables the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-albums",
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
},
{
"description": "Enables the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-assets",
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
},
{
"description": "Enables the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-immich-connection",
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Enables the get_state command without any pre-configured scope.", "description": "Enables the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:allow-get-state", "const": "wallpaper:allow-get-state",
"markdownDescription": "Enables the get_state command without any pre-configured scope." "markdownDescription": "Enables the get_state command without any pre-configured scope."
}, },
{
"description": "Enables the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-import-immich-assets",
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Enables the next_wallpaper command without any pre-configured scope.", "description": "Enables the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2258,6 +2294,12 @@
"const": "wallpaper:allow-set-setting", "const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope." "markdownDescription": "Enables the set_setting command without any pre-configured scope."
}, },
{
"description": "Denies the connect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-connect-immich",
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the delete_image command without any pre-configured scope.", "description": "Denies the delete_image command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2270,6 +2312,12 @@
"const": "wallpaper:deny-delete-images", "const": "wallpaper:deny-delete-images",
"markdownDescription": "Denies the delete_images command without any pre-configured scope." "markdownDescription": "Denies the delete_images command without any pre-configured scope."
}, },
{
"description": "Denies the disconnect_immich command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-disconnect-immich",
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
},
{ {
"description": "Denies the get_gallery command without any pre-configured scope.", "description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2282,12 +2330,36 @@
"const": "wallpaper:deny-get-image-ids", "const": "wallpaper:deny-get-image-ids",
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope." "markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
}, },
{
"description": "Denies the get_immich_albums command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-albums",
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
},
{
"description": "Denies the get_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-assets",
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
},
{
"description": "Denies the get_immich_connection command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-immich-connection",
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
},
{ {
"description": "Denies the get_state command without any pre-configured scope.", "description": "Denies the get_state command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "wallpaper:deny-get-state", "const": "wallpaper:deny-get-state",
"markdownDescription": "Denies the get_state command without any pre-configured scope." "markdownDescription": "Denies the get_state command without any pre-configured scope."
}, },
{
"description": "Denies the import_immich_assets command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-import-immich-assets",
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
},
{ {
"description": "Denies the next_wallpaper command without any pre-configured scope.", "description": "Denies the next_wallpaper command without any pre-configured scope.",
"type": "string", "type": "string",
+123 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Crop, Home, Images, Languages, LockKeyhole, Plus, RotateCcw, Save, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react"; import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
import { deleteImages, getGallery, getImageIds, getState, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type WallpaperState } from "./native"; import { connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type WallpaperState } from "./native";
import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local"; import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n";
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] }; const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
@@ -22,7 +23,7 @@ function IntervalRow({ value, onChange, label, paused, everyMinutes }: { value:
export default function App() { export default function App() {
const [state, setState] = useState(initial); const [state, setState] = useState(initial);
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home"); const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [language, setLanguage] = useState<Language>(initialLanguage); const [language, setLanguage] = useState<Language>(initialLanguage);
@@ -31,6 +32,16 @@ export default function App() {
const [galleryLoading, setGalleryLoading] = useState(false); const [galleryLoading, setGalleryLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [editing, setEditing] = useState<GalleryImage | null>(null); const [editing, setEditing] = useState<GalleryImage | null>(null);
const [immichConnection, setImmichConnection] = useState<ImmichConnection>({ configured: false, serverUrl: "", userName: "" });
const [immichUrl, setImmichUrl] = useState("");
const [immichApiKey, setImmichApiKey] = useState("");
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbum[]>([]);
const [immichAlbumId, setImmichAlbumId] = useState("");
const [immichAssets, setImmichAssets] = useState<ImmichAsset[]>([]);
const [immichSelected, setImmichSelected] = useState<Set<string>>(() => new Set());
const [immichPage, setImmichPage] = useState(1);
const [immichHasMore, setImmichHasMore] = useState(false);
const [immichLoading, setImmichLoading] = useState(false);
const phonePreviewRef = useRef<HTMLDivElement>(null); const phonePreviewRef = useRef<HTMLDivElement>(null);
const phoneImageRef = useRef<HTMLImageElement>(null); const phoneImageRef = useRef<HTMLImageElement>(null);
const previewPointers = useRef(new Map<number, { x: number; y: number }>()); const previewPointers = useRef(new Map<number, { x: number; y: number }>());
@@ -39,6 +50,12 @@ export default function App() {
const restoreGalleryScroll = useRef(false); const restoreGalleryScroll = useRef(false);
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []); useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
useEffect(() => {
getImmichConnection().then(connection => {
setImmichConnection(connection);
setImmichUrl(connection.serverUrl);
}).catch(() => undefined);
}, []);
useEffect(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
const timeout = window.setTimeout(() => setNotice(""), 3000); const timeout = window.setTimeout(() => setNotice(""), 3000);
@@ -59,6 +76,7 @@ export default function App() {
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); }; return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
}, [tab]); }, [tab]);
const t = translations[language]; const t = translations[language];
const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]); const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png"; const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]); const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
@@ -148,6 +166,78 @@ export default function App() {
void loadGallery(); void loadGallery();
} }
async function saveImmichConnection() {
setBusy(true);
try {
const connection = await connectImmich(immichUrl, immichApiKey);
setImmichConnection(connection);
setImmichUrl(connection.serverUrl);
setImmichApiKey("");
setNotice(it.connectionSuccess);
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.connectionFailed); }
finally { setBusy(false); }
}
async function removeImmichConnection() {
setBusy(true);
try {
setImmichConnection(await disconnectImmich());
setImmichUrl("");
setImmichApiKey("");
setImmichAssets([]);
setImmichAlbums([]);
setImmichSelected(new Set());
setNotice(it.disconnected);
} catch { setNotice(it.connectionFailed); }
finally { setBusy(false); }
}
async function loadImmichPhotos(albumId: string, page = 1, append = false) {
setImmichLoading(true);
try {
const result = await getImmichAssets(albumId || null, page, 30);
setImmichAssets(previous => append ? [...previous, ...result.items] : result.items);
setImmichPage(result.page);
setImmichHasMore(result.hasMore);
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally { setImmichLoading(false); }
}
async function openImmich() {
setTab("immich");
setImmichSelected(new Set());
setImmichLoading(true);
try {
const albums = await getImmichAlbums();
setImmichAlbums(albums);
await loadImmichPhotos(immichAlbumId);
} catch (error) {
setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed);
setImmichLoading(false);
}
}
function toggleImmichAsset(id: string) {
setImmichSelected(previous => {
const next = new Set(previous);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function importFromImmich() {
if (!immichSelected.size) return;
setBusy(true);
setNotice(it.importing);
try {
setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set());
setNotice(it.importSuccess);
setTab("home");
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally { setBusy(false); }
}
async function saveCrop() { async function saveCrop() {
if (!editing) return; if (!editing) return;
setBusy(true); setBusy(true);
@@ -251,6 +341,7 @@ export default function App() {
return <main className="app-shell"> return <main className="app-shell">
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ? {tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> : <header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); setTab("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>} <header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
<div className="content"> <div className="content">
@@ -272,7 +363,34 @@ export default function App() {
</section> </section>
<section className="settings-list"><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section> <section className="settings-list"><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
</> : tab === "settings" ? <section className="settings-page"><h2>{t.settings}</h2><p>{t.settingsIntro}</p><label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label><div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div></section> : tab === "gallery" ? </> : tab === "settings" ? <section className="settings-page">
<h2>{t.settings}</h2><p>{t.settingsIntro}</p>
<label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label>
<div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div>
<section className="immich-settings">
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
{immichConnection.configured ? <>
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div>
<button className="immich-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</button>
</> : <>
<label className="immich-field"><span>{it.serverUrl}</span><div><Server /><input type="url" inputMode="url" autoCapitalize="none" autoCorrect="off" value={immichUrl} onChange={event => setImmichUrl(event.target.value)} placeholder="https://photos.example.com" /></div></label>
{immichUrl.trim().toLowerCase().startsWith("http://") && <p className="immich-warning">{it.httpWarning}</p>}
<label className="immich-field"><span>{it.apiKey}</span><input type="password" autoCapitalize="none" autoCorrect="off" value={immichApiKey} onChange={event => setImmichApiKey(event.target.value)} placeholder={it.apiKeyPlaceholder} /></label>
<p className="immich-help">{it.apiKeyHelp}</p>
<button className="immich-connect" onClick={saveImmichConnection} disabled={busy || !immichUrl.trim() || !immichApiKey.trim()}><Cloud /> {busy ? t.pleaseWait : it.connect}</button>
</>}
</section>
<div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div>
</section> : tab === "immich" ?
<section className="immich-browser">
<label className="immich-album-filter"><span>{it.immich}</span><select value={immichAlbumId} onChange={event => { const id = event.target.value; setImmichAlbumId(id); setImmichSelected(new Set()); void loadImmichPhotos(id); }}><option value="">{it.allPhotos}</option>{immichAlbums.map(album => <option value={album.id} key={album.id}>{album.name} ({album.assetCount})</option>)}</select></label>
{!!immichAssets.length && <div className="selection-toolbar"><span>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</span><button onClick={() => setImmichSelected(immichSelected.size === immichAssets.length ? new Set() : new Set(immichAssets.map(asset => asset.id)))}>{immichSelected.size === immichAssets.length ? t.clearSelection : t.selectAll}</button></div>}
{immichAssets.length ? <div className="gallery-grid immich-grid">{immichAssets.map((asset, index) => <article className={immichSelected.has(asset.id) ? "chosen" : ""} key={asset.id}><button className="gallery-image-button" aria-label={`${immichSelected.has(asset.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={immichSelected.has(asset.id)} onClick={() => toggleImmichAsset(asset.id)}>{asset.thumbnailUrl ? <img src={asset.thumbnailUrl} alt={asset.fileName} /> : <span className="immich-placeholder"><Images /></span>}</button><span className="selection-check"><Check /></span></article>)}</div> : !immichLoading && <div className="gallery-empty"><Cloud /><h2>{it.noImmichPhotos}</h2></div>}
{immichLoading && <p className="gallery-status">{t.galleryLoading}</p>}
{!immichLoading && immichHasMore && <button className="load-more" onClick={() => loadImmichPhotos(immichAlbumId, immichPage + 1, true)}>{t.loadMore}</button>}
{!!immichSelected.size && <button className="primary immich-import" onClick={importFromImmich} disabled={busy}><CloudDownload /> {busy ? it.importing : `${it.importSelected} (${immichSelected.size})`}</button>}
</section> : tab === "gallery" ?
<section className="gallery-page" aria-label={t.myImages}> <section className="gallery-page" aria-label={t.myImages}>
{!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>} {!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>}
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>} {gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
@@ -295,6 +413,6 @@ export default function App() {
</div> </div>
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>} {notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
{tab !== "gallery" && tab !== "editor" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>} {tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
</main>; </main>;
} }
+141
View File
@@ -0,0 +1,141 @@
import type { Language } from "./i18n-local";
export type ImmichText = {
immich: string;
immichIntro: string;
serverUrl: string;
apiKey: string;
apiKeyPlaceholder: string;
apiKeyHelp: string;
connect: string;
connectedAs: string;
disconnect: string;
openImmich: string;
connectionSuccess: string;
connectionFailed: string;
disconnected: string;
httpWarning: string;
allPhotos: string;
selectImmichPhotos: string;
importSelected: string;
importing: string;
importSuccess: string;
immichLoadFailed: string;
noImmichPhotos: string;
};
export const immichTranslations: Record<Language, ImmichText> = {
de: {
immich: "Immich",
immichIntro: "Importiere Bilder aus deinem eigenen Immich-Server.",
serverUrl: "Server-URL",
apiKey: "API-Key",
apiKeyPlaceholder: "Nur beim Verbinden erforderlich",
apiKeyHelp: "Benötigt: user.read, album.read, asset.read, asset.view und asset.download.",
connect: "Verbinden und testen",
connectedAs: "Verbunden als",
disconnect: "Verbindung trennen",
openImmich: "Immich-Bilder öffnen",
connectionSuccess: "Immich wurde verbunden",
connectionFailed: "Immich-Verbindung fehlgeschlagen",
disconnected: "Immich-Verbindung wurde getrennt",
httpWarning: "HTTP ist unverschlüsselt. Verwende außerhalb deines Heimnetzes HTTPS.",
allPhotos: "Alle Fotos",
selectImmichPhotos: "Wähle Bilder zum Importieren aus",
importSelected: "Auswahl importieren",
importing: "Bilder werden importiert…",
importSuccess: "Immich-Bilder wurden importiert",
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
noImmichPhotos: "Keine Bilder gefunden",
},
en: {
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key",
apiKeyPlaceholder: "Required only when connecting", apiKeyHelp: "Required: user.read, album.read, asset.read, asset.view and asset.download.",
connect: "Connect and test", connectedAs: "Connected as", disconnect: "Disconnect", openImmich: "Open Immich photos",
connectionSuccess: "Immich connected", connectionFailed: "Immich connection failed", disconnected: "Immich disconnected",
httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…",
importSuccess: "Immich images imported", immichLoadFailed: "Could not load Immich images", noImmichPhotos: "No images found",
},
fr: {
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API",
apiKeyPlaceholder: "Requise uniquement à la connexion", apiKeyHelp: "Requis : user.read, album.read, asset.read, asset.view et asset.download.",
connect: "Connecter et tester", connectedAs: "Connecté en tant que", disconnect: "Déconnecter", openImmich: "Ouvrir les photos Immich",
connectionSuccess: "Immich est connecté", connectionFailed: "Échec de la connexion Immich", disconnected: "Immich est déconnecté",
httpWarning: "HTTP nest pas chiffré. Utilisez HTTPS hors de votre réseau domestique.", allPhotos: "Toutes les photos",
selectImmichPhotos: "Sélectionnez les images à importer", importSelected: "Importer la sélection", importing: "Importation des images…",
importSuccess: "Images Immich importées", immichLoadFailed: "Impossible de charger les images Immich", noImmichPhotos: "Aucune image trouvée",
},
es: {
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API",
apiKeyPlaceholder: "Solo se necesita al conectar", apiKeyHelp: "Necesita: user.read, album.read, asset.read, asset.view y asset.download.",
connect: "Conectar y probar", connectedAs: "Conectado como", disconnect: "Desconectar", openImmich: "Abrir fotos de Immich",
connectionSuccess: "Immich conectado", connectionFailed: "Error de conexión con Immich", disconnected: "Immich desconectado",
httpWarning: "HTTP no está cifrado. Usa HTTPS fuera de tu red doméstica.", allPhotos: "Todas las fotos",
selectImmichPhotos: "Selecciona imágenes para importar", importSelected: "Importar selección", importing: "Importando imágenes…",
importSuccess: "Imágenes de Immich importadas", immichLoadFailed: "No se pudieron cargar las imágenes de Immich", noImmichPhotos: "No se encontraron imágenes",
},
it: {
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API",
apiKeyPlaceholder: "Necessaria solo durante la connessione", apiKeyHelp: "Richiede: user.read, album.read, asset.read, asset.view e asset.download.",
connect: "Connetti e verifica", connectedAs: "Connesso come", disconnect: "Disconnetti", openImmich: "Apri foto Immich",
connectionSuccess: "Immich connesso", connectionFailed: "Connessione a Immich non riuscita", disconnected: "Immich disconnesso",
httpWarning: "HTTP non è crittografato. Usa HTTPS fuori dalla rete domestica.", allPhotos: "Tutte le foto",
selectImmichPhotos: "Seleziona le immagini da importare", importSelected: "Importa selezione", importing: "Importazione immagini…",
importSuccess: "Immagini Immich importate", immichLoadFailed: "Impossibile caricare le immagini Immich", noImmichPhotos: "Nessuna immagine trovata",
},
nl: {
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel",
apiKeyPlaceholder: "Alleen nodig bij verbinden", apiKeyHelp: "Vereist: user.read, album.read, asset.read, asset.view en asset.download.",
connect: "Verbinden en testen", connectedAs: "Verbonden als", disconnect: "Verbinding verbreken", openImmich: "Immich-fotos openen",
connectionSuccess: "Immich is verbonden", connectionFailed: "Immich-verbinding mislukt", disconnected: "Immich is losgekoppeld",
httpWarning: "HTTP is niet versleuteld. Gebruik HTTPS buiten je thuisnetwerk.", allPhotos: "Alle fotos",
selectImmichPhotos: "Selecteer afbeeldingen om te importeren", importSelected: "Selectie importeren", importing: "Afbeeldingen importeren…",
importSuccess: "Immich-afbeeldingen geïmporteerd", immichLoadFailed: "Immich-afbeeldingen konden niet worden geladen", noImmichPhotos: "Geen afbeeldingen gevonden",
},
pl: {
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API",
apiKeyPlaceholder: "Wymagany tylko podczas łączenia", apiKeyHelp: "Wymaga: user.read, album.read, asset.read, asset.view i asset.download.",
connect: "Połącz i sprawdź", connectedAs: "Połączono jako", disconnect: "Rozłącz", openImmich: "Otwórz zdjęcia Immich",
connectionSuccess: "Połączono z Immich", connectionFailed: "Nie udało się połączyć z Immich", disconnected: "Rozłączono Immich",
httpWarning: "HTTP nie jest szyfrowany. Poza siecią domową używaj HTTPS.", allPhotos: "Wszystkie zdjęcia",
selectImmichPhotos: "Wybierz obrazy do importu", importSelected: "Importuj wybrane", importing: "Importowanie obrazów…",
importSuccess: "Zaimportowano obrazy Immich", immichLoadFailed: "Nie udało się wczytać obrazów Immich", noImmichPhotos: "Nie znaleziono obrazów",
},
pt: {
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API",
apiKeyPlaceholder: "Necessária apenas ao ligar", apiKeyHelp: "Requer: user.read, album.read, asset.read, asset.view e asset.download.",
connect: "Ligar e testar", connectedAs: "Ligado como", disconnect: "Desligar", openImmich: "Abrir fotos do Immich",
connectionSuccess: "Immich ligado", connectionFailed: "Falha na ligação ao Immich", disconnected: "Immich desligado",
httpWarning: "HTTP não é encriptado. Use HTTPS fora da sua rede doméstica.", allPhotos: "Todas as fotos",
selectImmichPhotos: "Selecione imagens para importar", importSelected: "Importar seleção", importing: "A importar imagens…",
importSuccess: "Imagens do Immich importadas", immichLoadFailed: "Não foi possível carregar imagens do Immich", noImmichPhotos: "Nenhuma imagem encontrada",
},
ja: {
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
apiKeyPlaceholder: "接続時のみ必要", apiKeyHelp: "必要権限: user.read、album.read、asset.read、asset.view、asset.download。",
connect: "接続してテスト", connectedAs: "接続ユーザー", disconnect: "接続を解除", openImmich: "Immich の写真を開く",
connectionSuccess: "Immich に接続しました", connectionFailed: "Immich に接続できません", disconnected: "Immich の接続を解除しました",
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
},
ko: {
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
apiKeyPlaceholder: "연결할 때만 필요", apiKeyHelp: "필요 권한: user.read, album.read, asset.read, asset.view, asset.download.",
connect: "연결 및 테스트", connectedAs: "연결된 사용자", disconnect: "연결 해제", openImmich: "Immich 사진 열기",
connectionSuccess: "Immich에 연결했습니다", connectionFailed: "Immich 연결에 실패했습니다", disconnected: "Immich 연결을 해제했습니다",
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
},
"zh-CN": {
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
apiKeyPlaceholder: "仅连接时需要", apiKeyHelp: "需要权限:user.read、album.read、asset.read、asset.view 和 asset.download。",
connect: "连接并测试", connectedAs: "连接用户", disconnect: "断开连接", openImmich: "打开 Immich 照片",
connectionSuccess: "已连接 Immich", connectionFailed: "Immich 连接失败", disconnected: "已断开 Immich 连接",
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片",
},
};
+68
View File
@@ -25,6 +25,32 @@ export type GalleryPage = {
items: GalleryImage[]; items: GalleryImage[];
}; };
export type ImmichConnection = {
configured: boolean;
serverUrl: string;
userName: string;
};
export type ImmichAlbum = {
id: string;
name: string;
assetCount: number;
thumbnailUrl: string;
};
export type ImmichAsset = {
id: string;
fileName: string;
thumbnailUrl: string;
takenAt: string;
};
export type ImmichAssetsPage = {
items: ImmichAsset[];
page: number;
hasMore: boolean;
};
const demoState: WallpaperState = { const demoState: WallpaperState = {
imageCount: 3, imageCount: 3,
enabled: true, enabled: true,
@@ -38,6 +64,7 @@ const demoState: WallpaperState = {
const inTauri = () => "__TAURI_INTERNALS__" in window; const inTauri = () => "__TAURI_INTERNALS__" in window;
const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>(); const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>();
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 }; const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 };
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" };
export async function getState(): Promise<WallpaperState> { export async function getState(): Promise<WallpaperState> {
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState; return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
@@ -123,3 +150,44 @@ export async function nextWallpaper(): Promise<WallpaperState> {
} }
return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper"); return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper");
} }
export async function getImmichConnection(): Promise<ImmichConnection> {
return inTauri() ? invoke<ImmichConnection>("plugin:wallpaper|get_immich_connection") : demoImmichConnection;
}
export async function connectImmich(serverUrl: string, apiKey: string): Promise<ImmichConnection> {
if (inTauri()) return invoke<ImmichConnection>("plugin:wallpaper|connect_immich", { serverUrl, apiKey });
if (!serverUrl.trim() || !apiKey.trim()) throw new Error("Missing Immich credentials");
demoImmichConnection = { configured: true, serverUrl: serverUrl.trim(), userName: "Demo User" };
return demoImmichConnection;
}
export async function disconnectImmich(): Promise<ImmichConnection> {
if (inTauri()) return invoke<ImmichConnection>("plugin:wallpaper|disconnect_immich");
demoImmichConnection = { configured: false, serverUrl: "", userName: "" };
return demoImmichConnection;
}
export async function getImmichAlbums(): Promise<ImmichAlbum[]> {
if (inTauri()) return (await invoke<{ albums: ImmichAlbum[] }>("plugin:wallpaper|get_immich_albums")).albums;
return [{ id: "demo-album", name: "Nature", assetCount: 3, thumbnailUrl: "/wallpapers/alpine.png" }];
}
export async function getImmichAssets(albumId: string | null, page = 1, size = 30): Promise<ImmichAssetsPage> {
if (inTauri()) return invoke<ImmichAssetsPage>("plugin:wallpaper|get_immich_assets", { albumId, page, size });
return {
items: demoState.imageUrls.map((thumbnailUrl, index) => ({
id: `00000000-0000-4000-8000-00000000000${index}`,
fileName: `wallpaper-${index + 1}.jpg`,
thumbnailUrl,
takenAt: "2026-08-21T12:00:00Z",
})),
page,
hasMore: false,
};
}
export async function importImmichAssets(ids: string[]): Promise<WallpaperState> {
if (inTauri()) return invoke<WallpaperState>("plugin:wallpaper|import_immich_assets", { ids });
return { ...demoState, imageUrls: [...demoState.imageUrls] };
}
+33
View File
@@ -77,6 +77,39 @@ h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
.info { margin-top: 28px; padding: 20px; background: #eaf2e8; border-radius: 20px; } .info { margin-top: 28px; padding: 20px; background: #eaf2e8; border-radius: 20px; }
.info h3 { color: var(--green); margin: 0 0 6px; font-size: 16px; } .info h3 { color: var(--green); margin: 0 0 6px; font-size: 16px; }
.info p { margin: 0; font-size: 13px; line-height: 1.55; color: #526158; } .info p { margin: 0; font-size: 13px; line-height: 1.55; color: #526158; }
.immich-settings { margin-top: 24px; padding: 18px; border: 1px solid #d5dfd5; border-radius: 20px; background: white; box-shadow: 0 8px 24px rgba(24,56,35,.06); }
.immich-title { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; }
.immich-title > span { width: 42px; height: 42px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 13px; color: white; background: var(--green); }
.immich-title svg { width: 22px; }
.immich-title h3 { margin: 0 0 3px; font-size: 18px; }
.immich-title p { margin: 0; color: #69736c; font-size: 12px; line-height: 1.4; }
.immich-field { display: block; margin-top: 13px; }
.immich-field > span { display: block; margin-bottom: 6px; color: #526158; font-size: 11px; font-weight: 750; }
.immich-field > div { position: relative; }
.immich-field svg { position: absolute; left: 12px; top: 50%; width: 17px; color: #63806b; transform: translateY(-50%); }
.immich-field input { width: 100%; height: 46px; padding: 0 12px; border: 1px solid #c7d4c9; border-radius: 12px; color: #18201b; background: #fbfcfa; font-size: 13px; }
.immich-field div input { padding-left: 38px; }
.immich-field input:focus { outline: 2px solid rgba(20,93,50,.18); border-color: var(--green); }
.immich-help, .immich-warning { margin: 7px 2px 0; color: #748078; font-size: 10px; line-height: 1.4; }
.immich-warning { padding: 8px 10px; border-radius: 9px; color: #795518; background: #fff4d7; }
.immich-connect, .immich-open { width: 100%; height: 48px; margin-top: 15px; border: 0; border-radius: 13px; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; background: var(--green); font-size: 13px; font-weight: 800; }
.immich-connect:disabled, .immich-open:disabled, .immich-disconnect:disabled { opacity: .55; }
.immich-connect svg, .immich-open svg, .immich-disconnect svg { width: 18px; }
.immich-open svg:last-child { margin-left: auto; }
.immich-open { padding: 0 14px; }
.immich-connected { padding: 12px; border-radius: 12px; background: var(--sage); display: flex; flex-direction: column; gap: 3px; }
.immich-connected strong { color: var(--green); font-size: 12px; }
.immich-connected span { overflow: hidden; color: #647069; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.immich-disconnect { width: 100%; height: 40px; margin-top: 8px; border: 0; border-radius: 11px; color: #7a3434; background: #f5eaea; display: flex; align-items: center; justify-content: center; gap: 7px; font-size: 11px; font-weight: 750; }
.immich-header .import-selection { color: white; background: var(--green); }
.immich-browser { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.immich-album-filter { display: block; margin-bottom: 10px; }
.immich-album-filter > span { display: block; margin: 0 0 6px 2px; color: #6a756d; font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .8px; }
.immich-album-filter select { width: 100%; height: 45px; padding: 0 38px 0 12px; border: 1px solid #c8d5ca; border-radius: 13px; color: var(--green); background: white; font-size: 13px; font-weight: 750; }
.immich-grid article { aspect-ratio: 1; }
.immich-placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: #78907d; background: var(--sage); }
.immich-placeholder svg { width: 28px; }
.immich-import { position: sticky; z-index: 4; bottom: max(16px, env(safe-area-inset-bottom)); margin-bottom: 0; box-shadow: 0 10px 30px rgba(20,93,50,.3); }
nav { position: fixed; z-index: 10; bottom: 0; left: 50%; transform: translateX(-50%); width: min(100%, 480px); height: calc(76px + env(safe-area-inset-bottom)); padding: 8px 34px env(safe-area-inset-bottom); background: rgba(248,250,247,.96); backdrop-filter: blur(16px); border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } nav { position: fixed; z-index: 10; bottom: 0; left: 50%; transform: translateX(-50%); width: min(100%, 480px); height: calc(76px + env(safe-area-inset-bottom)); padding: 8px 34px env(safe-area-inset-bottom); background: rgba(248,250,247,.96); backdrop-filter: blur(16px); border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
nav button { border: 0; color: #657068; background: transparent; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 3px; font-size: 11px; font-weight: 650; } nav button { border: 0; color: #657068; background: transparent; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 3px; font-size: 11px; font-weight: 650; }
nav button.active { color: var(--green); background: var(--sage); } nav button.active { color: var(--green); background: var(--sage); }