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
@@ -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
}
@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
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor()
@@ -136,4 +145,38 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@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")
}
@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_interval",
"next_wallpaper",
"get_immich_connection",
"connect_immich",
"disconnect_immich",
"get_immich_albums",
"get_immich_assets",
"import_immich_assets",
];
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-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`
## Permission Table
@@ -24,6 +30,32 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
</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>
<td>
@@ -79,6 +111,32 @@ Denies the delete_images command without any pre-configured scope.
<tr>
<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`
</td>
@@ -131,6 +189,84 @@ Denies the get_image_ids command without any pre-configured scope.
<tr>
<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`
</td>
@@ -157,6 +293,32 @@ Denies the get_state command without any pre-configured scope.
<tr>
<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`
</td>
+1 -1
View File
@@ -1,3 +1,3 @@
[default]
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": {
"type": "string",
"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.",
"type": "string",
@@ -318,6 +330,18 @@
"const": "deny-delete-images",
"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.",
"type": "string",
@@ -342,6 +366,42 @@
"const": "deny-get-image-ids",
"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.",
"type": "string",
@@ -354,6 +414,18 @@
"const": "deny-get-state",
"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.",
"type": "string",
@@ -415,10 +487,10 @@
"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",
"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> {
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;
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_setting,
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| {
#[cfg(mobile)]
+18
View File
@@ -70,4 +70,22 @@ impl<R: Runtime> Wallpaper<R> {
.run_mobile_plugin("nextWallpaper", ())
.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 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>,
}