Salta al contenuto principale

03 - Tauri plugins ecosystem 2027

I plugin di Tauri 2: come aggiungerne uno ufficiale, concedere i permessi, valutare quelli della comunità e scriverne uno tuo con comandi e stato.

Cosa ne pensi di questo articolo?

03 - Tauri plugins ecosystem 2027
Tauri Rust DesktopFoto:Daniil KomovPexels

In Breve

I plugin di Tauri 2: come aggiungerne uno ufficiale, concedere i permessi, valutare quelli della comunità e scriverne uno tuo con comandi e stato.

Il core di Tauri è piccolo di proposito: il resto sono plugin

Ti serve una finestra di dialogo per scegliere un file, un posto dove salvare le impostazioni, le notifiche di sistema, l'aggiornamento automatico. In Tauri 2 nessuna di queste cose è nel core: la documentazione dice che «by design, the Tauri core does not contain features not needed by everyone», e le funzioni opzionali vivono in plugin. Un plugin è un crate Rust, con in più, se serve, un pacchetto npm che espone i suoi comandi al frontend e una libreria Kotlin o Swift per il mobile. La tabella del repository ufficiale dei plugin, al commit che uso più sotto, ne elenca 31; qui impari ad aggiungerne uno, a concedergli i permessi, a valutare quelli della comunità e a scriverne uno tuo.

Che cosa ho provato: ho eseguito con Node 24 la parte TypeScript (il pacchetto ufficiale del plugin store e il binding di un plugin mio, con un finto core), e ho eseguito due script Python che leggono il repository ufficiale e il registro dei crate. Il codice Rust dei plugin è uno schema costruito sulla guida ufficiale e non l'ho compilato, perché sulla macchina di scrittura non ci sono Rust né Cargo. Per capire i comandi e i tipi che attraversano il confine leggi prima Tipi condivisi fra Rust e TypeScript.

Aggiungere un plugin ufficiale

Prendi il plugin store, un archivio chiave-valore persistente. La pagina del plugin offre due strade equivalenti: un comando che fa tutto e la procedura manuale in tre passi.

Installazione automatica e manuale di un plugin
# automatico: aggiunge la dipendenza Rust, il pacchetto npm e la registrazione
npm run tauri add store

# manuale, equivalente
cargo add tauri-plugin-store          # da src-tauri
npm install @tauri-apps/plugin-store  # dalla radice del progetto

tauri add store aggiunge il crate tauri-plugin-store alle dipendenze Rust, installa il pacchetto @tauri-apps/plugin-store e inserisce la registrazione nel codice; la documentazione dei permessi precisa che, se usi la CLI, anche il permesso default del plugin viene aggiunto alla configurazione. La strada manuale ti serve quando vuoi vedere ogni modifica o quando lavori in un repository con regole proprie. In entrambi i casi controlla la versione del compilatore: il repository dei plugin dichiara che tutti richiedono almeno Rust 1.90.

Registrazione del plugin nel builder dell'app
// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::new().build())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Il punto che conta è .plugin(...) sul tauri::Builder: finché non registri il plugin qui, i suoi comandi non esistono. Il nome del crate Rust usa il trattino basso (tauri_plugin_store), mentre quello su crates.io usa il trattino. Il resto è il solito run dell'app.

Come il frontend parla con un plugin

Un plugin espone comandi, e il pacchetto npm è soltanto una comodità che li chiama con invoke. Per vedere i messaggi veri ho eseguito il pacchetto ufficiale con un finto core che stampa ogni chiamata.

Le chiamate IPC emesse dal pacchetto del plugin store
import { mockIPC } from '@tauri-apps/api/mocks';
import { load } from '@tauri-apps/plugin-store';

(globalThis as any).window = globalThis;

// Finto core: registra ogni comando che il pacchetto JavaScript invia.
const valori = new Map<string, unknown>();
mockIPC((cmd, args) => {
  console.log('IPC ->', cmd, JSON.stringify(args));
  const a = args as { key?: string; value?: unknown };
  if (cmd === 'plugin:store|load') return 1; // identificativo (rid) della risorsa
  if (cmd === 'plugin:store|set') { valori.set(a.key!, a.value); return; }
  if (cmd === 'plugin:store|get') return [valori.get(a.key!), valori.has(a.key!)];
  if (cmd === 'plugin:store|save') return;
});

const store = await load('impostazioni.json', { autoSave: false });
await store.set('tema', { nome: 'chiaro' });
console.log('letto:', JSON.stringify(await store.get('tema')));
await store.save();

// Output (Node 24, due esecuzioni identiche):
// IPC -> plugin:store|load {"path":"impostazioni.json","options":{"autoSave":false}}
// IPC -> plugin:store|set {"rid":1,"key":"tema","value":{"nome":"chiaro"}}
// IPC -> plugin:store|get {"rid":1,"key":"tema"}
// letto: {"nome":"chiaro"}
// IPC -> plugin:store|save {"rid":1}

L'uscita mostra la convenzione di nome: ogni comando di un plugin si chiama plugin:<nome>|<comando>, con la barra verticale come separatore. Il load restituisce un identificativo di risorsa (rid, nel finto core vale 1) che le chiamate successive riportano, perché lo store vive in Rust e il frontend ne tiene solo un riferimento. Il get restituisce una coppia valore e flag di esistenza, forma che ho verificato nel codice del pacchetto. Tre operazioni sono costate quattro messaggi IPC: ogni chiamata è un passaggio attraverso il core, e se hai bisogno di molte letture in sequenza ha senso tenere una copia in memoria nel frontend.

I permessi: un plugin senza capability non risponde

I comandi di un plugin non sono raggiungibili dal frontend se non li concedi. La guida allo sviluppo dei plugin lo dice chiaramente: per impostazione predefinita i comandi non sono accessibili e una chiamata produce un rifiuto di tipo «denied». I permessi si assegnano in un file di capability.

Capability che abilita il plugin store
{
  "identifier": "main-capability",
  "description": "Permessi della finestra principale",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "store:default"
  ]
}

Gli identificativi seguono la forma nome:default per l'insieme predefinito e nome:allow-comando o nome:deny-comando per un comando singolo; il prefisso tauri-plugin- si omette. Il default del plugin store abilita quattordici operazioni (da allow-load a allow-save), il che va bene per un archivio di impostazioni. Per plugin potenti come il file system, la shell o il client HTTP, il default è spesso troppo largo: concedi i permessi singoli di cui hai bisogno e, dove il plugin li prevede, limita i percorsi o gli indirizzi con gli scope, che sono vincoli per comando come «solo sotto $HOME/*».

Che cosa copre davvero la lista ufficiale

I plugin ufficiali non sono uniformi: alcuni sono solo per il desktop, altri solo per il mobile. Il README del repository li riporta in una tabella con cinque colonne di piattaforma, e ho scritto uno script che la legge a un commit fissato e li classifica.

Classificazione dei plugin ufficiali per piattaforma
import re
import urllib.request

SHA = "9aa4f717152cdeceed4bada9c849ad68a9dc402c"
URL = f"https://raw.githubusercontent.com/tauri-apps/plugins-workspace/{SHA}/README.md"

with urllib.request.urlopen(URL, timeout=30) as response:
    testo = response.read().decode("utf-8")

# Colonne della tabella: nome | descrizione | Win | Mac | Lin | iOS | And
righe = [r for r in testo.splitlines() if re.match(r"\| \[[a-z-]+\]\(plugins/", r)]
gruppi = {"desktop e mobile": [], "solo desktop": [], "solo mobile": [], "altro o non dichiarato": []}

for riga in righe:
    celle = [c.strip() for c in riga.strip("|").split("|")]
    nome = re.match(r"\[([a-z-]+)\]", celle[0]).group(1)
    desktop = celle[2:5]
    mobile = celle[5:7]
    if all("✅" in c for c in desktop + mobile):
        gruppi["desktop e mobile"].append(nome)
    elif all("✅" in c for c in desktop) and all("❌" in c for c in mobile):
        gruppi["solo desktop"].append(nome)
    elif all("✅" in c for c in mobile) and all("✅" not in c for c in desktop):
        gruppi["solo mobile"].append(nome)
    else:
        gruppi["altro o non dichiarato"].append(nome)

print(f"plugin nella tabella: {len(righe)}")
for etichetta, nomi in gruppi.items():
    print(f"{etichetta} ({len(nomi)}): {', '.join(nomi)}")

# Output (README al commit indicato, due esecuzioni identiche):
# plugin nella tabella: 31
# desktop e mobile (11): clipboard-manager, deep-link, dialog, http, log, notification, opener, os, sql, store, upload
# solo desktop (6): autostart, cli, positioner, single-instance, updater, window-state
# solo mobile (6): barcode-scanner, biometric, geolocation, haptics, nfc, torch
# altro o non dichiarato (8): fs, global-shortcut, localhost, persisted-scope, process, shell, stronghold, websocket

Lo script isola le righe della tabella, legge le cinque colonne (Windows, macOS, Linux, iOS, Android) e le divide in quattro gruppi. Il commit è fissato per rendere l'uscita ripetibile (il ramo cambia, e con lui il risultato). La legenda del README dice che ✅ vuol dire «supportato (anche parzialmente)», ❌ «non supportato» e ? «sconosciuto, non provato o pianificato»: il gruppo «altro» contiene quindi plugin come fs e shell, di cui il repository non dichiara il mobile, non plugin che non funzionano. Il dato che pesa di più è l'updater nel gruppo «solo desktop»: su mobile gli aggiornamenti non passano da questo plugin.

Per orientarti, i 31 plugin si raggruppano per funzione così:

  • Dati e archivi: store, sql, fs, stronghold, persisted-scope.
  • Integrazione col sistema: autostart, deep-link, single-instance, window-state, global-shortcut, positioner.
  • Interfaccia: dialog, notification, clipboard-manager.
  • Rete: http, upload, websocket, localhost.
  • Processo e app: shell, process, cli, os, log, opener, updater.
  • Hardware mobile: barcode-scanner, biometric, geolocation, haptics, nfc, torch.

Il raggruppamento serve soprattutto a mostrarti le alternative. Per salvare dati hai store, sql e fs, che risolvono problemi diversi: coppie chiave-valore, interrogazioni relazionali, file qualunque. Prima di cercare un plugin della comunità controlla se uno di questi sei gruppi già copre il bisogno, perché un plugin ufficiale è mantenuto dal progetto Tauri, mentre uno terzo dipende dal suo autore.

Plugin della comunità: utili, ma con il codice del tuo processo principale

Il sito di Tauri elenca anche plugin della comunità, dai client Bluetooth alle integrazioni con Sentry. La pagina li presenta come risorse della comunità, non come software verificato dal progetto. Il motivo per cui conta è nel modello a processi: un plugin gira nel core process, l'unico componente con accesso pieno al sistema operativo, e le capability non proteggono da codice Rust malevolo o da una dipendenza compromessa, come ammette la loro stessa pagina nella sezione sui confini di sicurezza.

Controllare un crate prima di adottarlo
import json
import urllib.request

CRATES = ["tauri-plugin-store", "tauri-plugin-system-info", "tauri-plugin-prevent-default"]
HEADERS = {"User-Agent": "controllo-plugin-esempio"}


def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS), timeout=30) as r:
        return json.load(r)


for nome in CRATES:
    crate = get(f"https://crates.io/api/v1/crates/{nome}")["crate"]
    versione = crate["max_stable_version"] or crate["max_version"]
    dipendenze = get(f"https://crates.io/api/v1/crates/{nome}/{versione}/dependencies")["dependencies"]
    tauri = sorted({d["req"] for d in dipendenze if d["crate_id"] == "tauri" and d["kind"] == "normal"})
    print(f"{nome} {versione} aggiornato il {crate['updated_at'][:10]}, richiede tauri {', '.join(tauri) or 'non dichiarato'}")

# Output del 2026-11-13 (cambia nel tempo, due esecuzioni consecutive identiche):
# tauri-plugin-store 2.5.1 aggiornato il 2026-11-10, richiede tauri ^2.12
# tauri-plugin-system-info 2.0.9 aggiornato il 2025-02-14, richiede tauri ^2.0.1
# tauri-plugin-prevent-default 6.0.1 aggiornato il 2026-10-05, richiede tauri ^2.12

Lo script chiede al registro crates.io la versione stabile e la data dell'ultimo aggiornamento, poi legge le dipendenze di quella versione per estrarre il requisito su tauri. Il risultato di oggi è istruttivo: il plugin ufficiale richiede tauri ^2.12, un plugin della comunità aggiornato in ottobre richiede lo stesso, mentre un altro è fermo al 14 febbraio 2025 (circa 21 mesi prima della data di consultazione) pur dichiarando ^2.0.1, compatibile ma non più seguito. Non è un verdetto: è il tipo di segnale da cui partire, prima di leggere il codice del plugin, aprire le sue issue e verificare la licenza. L'uscita cambia nel tempo, quindi non copiarla come valore fisso.

Scrivere un plugin tuo

Quando la logica nativa è usata da più progetti, o devi isolarla dietro permessi chiari, conviene un plugin. La CLI crea lo scheletro: npx @tauri-apps/cli plugin new contatore produce la cartella tauri-plugin-contatore con src/commands.rs, src/lib.rs, la cartella permissions, guest-js per il binding TypeScript e, con i flag --android e --ios, i progetti Kotlin e Swift. Il nome deve rispettare la sintassi degli identificativi: lettere minuscole, cifre e trattini, senza trattino iniziale o finale.

Un plugin con stato condiviso (schema da adattare)
// tauri-plugin-contatore/src/lib.rs
use std::sync::Mutex;
use tauri::{
    plugin::{Builder, TauriPlugin},
    Manager, Runtime,
};

mod commands;

#[derive(Default)]
pub struct Contatore(pub Mutex<u32>);

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("contatore")
        .invoke_handler(tauri::generate_handler![
            commands::incrementa,
            commands::valore
        ])
        .setup(|app, _api| {
            app.manage(Contatore::default());
            Ok(())
        })
        .build()
}

// tauri-plugin-contatore/src/commands.rs
use tauri::{command, State};

use crate::Contatore;

#[command]
pub(crate) fn incrementa(stato: State<'_, Contatore>, passo: u32) -> u32 {
    let mut valore = stato.0.lock().unwrap();
    *valore += passo;
    *valore
}

#[command]
pub(crate) fn valore(stato: State<'_, Contatore>) -> u32 {
    *stato.0.lock().unwrap()
}

Builder::new("contatore") dà il nome al plugin e determina il prefisso dei comandi. invoke_handler registra i due comandi, esattamente come in un'app. Il punto dove nasce lo stato è setup: lì app.manage(Contatore::default()) mette a disposizione lo stato, che i comandi ricevono come State. Il contatore è un u32 dietro un Mutex perché i comandi possono arrivare da più finestre insieme; ho evitato u64 per non cadere nel limite dei 253 descritto nell'articolo sui tipi. Sono previsti altri ganci (on_navigation, on_webview_ready, on_event, on_drop): il più utile per la pulizia è on_event con RunEvent::Exit.

Script di build del plugin e dipendenze (schema da adattare)
// tauri-plugin-contatore/build.rs
const COMMANDS: &[&str] = &["incrementa", "valore"];

fn main() {
    tauri_plugin::Builder::new(COMMANDS).build();
}

// tauri-plugin-contatore/Cargo.toml (parti rilevanti)
// [dependencies]
// tauri = "2"
// [build-dependencies]
// tauri-plugin = { version = "2", features = ["build"] }

L'elenco COMMANDS in build.rs genera automaticamente i permessi allow-incrementa, deny-incrementa e gli analoghi per valore: i nomi devono coincidere con le funzioni Rust, in snake_case. Se aggiungi un comando e dimentichi la riga, non ottieni il permesso generato e devi scrivere a mano il file che lo definisce.

Il permesso di default del plugin
"$schema" = "schemas/schema.json"

[default]
description = "Consente di leggere e incrementare il contatore"
permissions = ["allow-incrementa", "allow-valore"]

Il file in permissions/default.toml raggruppa i due permessi generati nell'insieme default, che l'app potrà abilitare scrivendo contatore:default. Ho validato il file come TOML. Se un plugin abilita il default senza pensarci, l'utente ottiene permessi che non ha scelto: tieni il default minimo e di sola lettura quando puoi.

Il binding TypeScript del plugin, eseguito con un finto core
import { invoke } from '@tauri-apps/api/core';

// guest-js/index.ts del plugin: un'unica funzione per comando Rust
export async function incrementa(passo = 1): Promise<number> {
  return await invoke<number>('plugin:contatore|incrementa', { passo });
}

export async function valore(): Promise<number> {
  return await invoke<number>('plugin:contatore|valore');
}

// prova.ts (eseguita con Node 24)
import { mockIPC } from '@tauri-apps/api/mocks';
import { incrementa, valore } from './contatore.ts';

(globalThis as any).window = globalThis;

// Finto core: fa quello che farebbero i due comandi Rust del plugin.
let contatore = 0;
mockIPC((cmd, args) => {
  console.log('IPC ->', cmd, JSON.stringify(args));
  if (cmd === 'plugin:contatore|incrementa') return (contatore += (args as { passo: number }).passo);
  if (cmd === 'plugin:contatore|valore') return contatore;
});

console.log(await incrementa());
console.log(await incrementa(5));
console.log(await valore());

// Output (Node 24, due esecuzioni identiche):
// IPC -> plugin:contatore|incrementa {"passo":1}
// 1
// IPC -> plugin:contatore|incrementa {"passo":5}
// 6
// IPC -> plugin:contatore|valore {}
// 6

Il binding è una funzione per comando, ciascuna con il percorso plugin:contatore|nome e gli argomenti nominati come nel Rust. Per l'esecuzione ho scritto un finto core che simula il comportamento dei due comandi, quindi l'uscita prova la forma dei messaggi, non il codice Rust. Il file contatore.ts è il binding vero e proprio, mentre prova.ts contiene il finto core e le tre chiamate. Dall'app si registra il plugin e si concede il permesso così:

Uso del plugin dall'app
// nell'app che usa il plugin: src-tauri/src/lib.rs
tauri::Builder::default()
    .plugin(tauri_plugin_contatore::init())
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

// e nella capability della finestra: "contatore:default"

Se la capability manca, il comando risponde con il rifiuto di cui sopra: è la prima cosa da controllare quando un comando del tuo plugin «non funziona». Un plugin pubblicato su crates.io segue la convenzione tauri-plugin-nome, e puoi pubblicare il pacchetto npm in parallelo.

Domande frequenti

Come aggiungo un plugin ufficiale a un'app Tauri 2?

Con npm run tauri add nome-plugin, che installa il crate e il pacchetto npm e registra il plugin; poi concedi i permessi in una capability. Se preferisci fare tutto a mano servono cargo add, npm install e la riga .plugin(...) nel builder.

I plugin ufficiali funzionano anche su Android e iOS?

Alcuni sì, altri no. Nella tabella ufficiale al commit citato, undici plugin dichiarano desktop e mobile, sei solo desktop (updater compreso), sei solo mobile e otto non dichiarano il mobile. La guida alla distribuzione mobile è in Tauri mobile su Android e iOS.

Posso scrivere un plugin in Kotlin o Swift?

Sì: un plugin può includere una libreria Android in Kotlin e un pacchetto Swift per iOS, e la documentazione prevede un'apposita guida di sviluppo mobile. La parte Rust fa da ponte e il frontend continua a chiamare gli stessi comandi.

Posso eseguire codice Python o Node dentro un'app Tauri?

Non come plugin Rust. Il plugin ufficiale shell permette di avviare processi figli, e la lista della comunità cita plugin che affiancano Python o un runtime JavaScript; non li ho provati e li valuterei con gli stessi controlli della sezione sui plugin terzi.

Che cosa sai fare adesso

Sai aggiungere un plugin ufficiale con la CLI o a mano, sai che ogni comando si chiama plugin:nome|comando e che senza capability il frontend riceve un rifiuto. Sai leggere la tabella di compatibilità dei plugin per piattaforma e valutare un crate della comunità con tre dati misurabili (versione, ultimo aggiornamento, requisito su tauri). Sai anche scrivere un plugin con stato, comandi e permessi. Prima di distribuire l'app c'è la firma del codice: Distribuzione su macOS, Windows e Linux.

Fonti

Il pacchetto @tauri-apps/plugin-store 2.5.1 e il binding del plugin d'esempio sono stati eseguiti con Node 24.11 (due esecuzioni identiche); i due script Python sono stati eseguiti due volte con la stessa uscita; il file dei permessi è stato validato come TOML. Il codice Rust del plugin è uno schema da adattare, non compilato: sulla macchina di scrittura non ci sono Rust né Cargo.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti