mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
ci: remove stronghold plugin from e2e tests (#3638)
* ci: remove stronghold plugin from e2e tests * revert unrelated change
This commit is contained in:
Generated
-1
@@ -237,7 +237,6 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-stronghold",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-upload",
|
||||
"tauri-plugin-websocket",
|
||||
|
||||
-17
@@ -32,23 +32,6 @@ license = "Apache-2.0 OR MIT"
|
||||
rust-version = "1.77.2"
|
||||
repository = "https://github.com/tauri-apps/plugins-workspace"
|
||||
|
||||
# Stronghold encrypts its snapshots with scrypt: unoptimized, saving or loading one
|
||||
# takes ~45s instead of ~1s, which stalls the (debug) example app and its e2e suite.
|
||||
[profile.dev.package.scrypt]
|
||||
opt-level = 3
|
||||
[profile.dev.package.salsa20]
|
||||
opt-level = 3
|
||||
[profile.dev.package.pbkdf2]
|
||||
opt-level = 3
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
[profile.dev.package.hmac]
|
||||
opt-level = 3
|
||||
[profile.dev.package.iota-crypto]
|
||||
opt-level = 3
|
||||
[profile.dev.package.rust-argon2]
|
||||
opt-level = 3
|
||||
|
||||
# default to small, optimized release binaries
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"@tauri-apps/plugin-shell": "workspace:*",
|
||||
"@tauri-apps/plugin-sql": "workspace:*",
|
||||
"@tauri-apps/plugin-store": "workspace:*",
|
||||
"@tauri-apps/plugin-stronghold": "workspace:*",
|
||||
"@tauri-apps/plugin-updater": "workspace:*",
|
||||
"@tauri-apps/plugin-upload": "workspace:*",
|
||||
"@tauri-apps/plugin-websocket": "workspace:*",
|
||||
|
||||
@@ -43,7 +43,6 @@ tauri-plugin-deep-link = { path = "../../../plugins/deep-link", version = "2.4.1
|
||||
tauri-plugin-sql = { path = "../../../plugins/sql", version = "2.4.1", features = [
|
||||
"sqlite",
|
||||
] }
|
||||
tauri-plugin-stronghold = { path = "../../../plugins/stronghold", version = "2.3.2" }
|
||||
tauri-plugin-websocket = { path = "../../../plugins/websocket", version = "2.4.3" }
|
||||
|
||||
# WebDriver automation bridge, used by the plugins e2e suite (packages/api-e2e).
|
||||
|
||||
@@ -139,10 +139,6 @@
|
||||
"deep-link:allow-is-registered",
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"stronghold:default",
|
||||
"stronghold:allow-destroy",
|
||||
"stronghold:allow-remove-secret",
|
||||
"stronghold:allow-remove-store-record",
|
||||
"websocket:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ mod tray;
|
||||
use serde::Serialize;
|
||||
use tauri::{
|
||||
webview::{PageLoadEvent, WebviewWindowBuilder},
|
||||
App, AppHandle, Emitter, Listener, Manager, RunEvent, WebviewUrl,
|
||||
App, AppHandle, Emitter, Listener, RunEvent, WebviewUrl,
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -71,14 +71,6 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.setup(move |app| {
|
||||
// the argon2 salt lives next to the snapshots the frontend creates
|
||||
let local_data_dir = app.path().app_local_data_dir()?;
|
||||
std::fs::create_dir_all(&local_data_dir)?;
|
||||
app.handle().plugin(
|
||||
tauri_plugin_stronghold::Builder::with_argon2(&local_data_dir.join("salt.txt"))
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
// registered before the tray, whose events it tracks
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
import DeepLink from './views/DeepLink.svelte'
|
||||
import Positioner from './views/Positioner.svelte'
|
||||
import Sql from './views/Sql.svelte'
|
||||
import Stronghold from './views/Stronghold.svelte'
|
||||
import WebSocket from './views/WebSocket.svelte'
|
||||
|
||||
import TitleBar from './lib/TitleBar.svelte'
|
||||
@@ -147,11 +146,6 @@
|
||||
component: Sql,
|
||||
icon: 'i-ph-database'
|
||||
},
|
||||
{
|
||||
label: 'Stronghold',
|
||||
component: Stronghold,
|
||||
icon: 'i-ph-lock-key'
|
||||
},
|
||||
{
|
||||
label: 'Deep link',
|
||||
component: DeepLink,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script>
|
||||
import { Stronghold } from '@tauri-apps/plugin-stronghold'
|
||||
import { appLocalDataDir, join } from '@tauri-apps/api/path'
|
||||
|
||||
export let onMessage
|
||||
|
||||
const clientName = 'api-example'
|
||||
const storeKey = 'secret'
|
||||
|
||||
let password = 'password'
|
||||
let stronghold
|
||||
let store
|
||||
let record = ''
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const path = await join(await appLocalDataDir(), 'api.stronghold')
|
||||
stronghold = await Stronghold.load(path, password)
|
||||
let client
|
||||
try {
|
||||
client = await stronghold.loadClient(clientName)
|
||||
} catch {
|
||||
client = await stronghold.createClient(clientName)
|
||||
}
|
||||
store = client.getStore()
|
||||
const value = await store.get(storeKey)
|
||||
record = value ? new TextDecoder().decode(value) : ''
|
||||
onMessage('Stronghold loaded')
|
||||
} catch (error) {
|
||||
stronghold = null
|
||||
onMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await store.insert(storeKey, Array.from(new TextEncoder().encode(record)))
|
||||
await stronghold.save()
|
||||
onMessage('Record saved')
|
||||
} catch (error) {
|
||||
onMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function unload() {
|
||||
await stronghold.unload().catch(onMessage)
|
||||
stronghold = null
|
||||
store = null
|
||||
record = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if stronghold}
|
||||
<div class="flex flex-row gap-2">
|
||||
<input class="input grow" placeholder="Secret" bind:value={record} />
|
||||
<button class="btn" on:click={save}>Save</button>
|
||||
<button class="btn" on:click={unload}>Lock</button>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="flex flex-row gap-2" on:submit|preventDefault={load}>
|
||||
<input
|
||||
class="input grow"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
/>
|
||||
<button class="btn" type="submit">Unlock</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -83,7 +83,6 @@ skipped on desktop, and the rest run everywhere with the odd test gated.
|
||||
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
|
||||
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
|
||||
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
|
||||
| `stronghold` | Store records (persisted across reloads), wrong-password and unknown-client errors, vault secrets, BIP39/SLIP10 derivation and Ed25519 signing. | Same. |
|
||||
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
|
||||
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
|
||||
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@tauri-apps/plugin-shell": "workspace:*",
|
||||
"@tauri-apps/plugin-sql": "workspace:*",
|
||||
"@tauri-apps/plugin-store": "workspace:*",
|
||||
"@tauri-apps/plugin-stronghold": "workspace:*",
|
||||
"@tauri-apps/plugin-updater": "workspace:*",
|
||||
"@tauri-apps/plugin-upload": "workspace:*",
|
||||
"@tauri-apps/plugin-websocket": "workspace:*",
|
||||
|
||||
@@ -26,7 +26,6 @@ import type * as Process from '@tauri-apps/plugin-process'
|
||||
import type * as Shell from '@tauri-apps/plugin-shell'
|
||||
import type * as Sql from '@tauri-apps/plugin-sql'
|
||||
import type * as Store from '@tauri-apps/plugin-store'
|
||||
import type * as Stronghold from '@tauri-apps/plugin-stronghold'
|
||||
import type * as Updater from '@tauri-apps/plugin-updater'
|
||||
import type * as Upload from '@tauri-apps/plugin-upload'
|
||||
import type * as WebSocket from '@tauri-apps/plugin-websocket'
|
||||
@@ -52,7 +51,6 @@ export interface CommonPluginApi {
|
||||
shell: typeof Shell
|
||||
sql: typeof Sql.default
|
||||
store: typeof Store
|
||||
stronghold: typeof Stronghold
|
||||
upload: typeof Upload
|
||||
websocket: typeof WebSocket.default
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ const commonSurface: Surface<CommonPluginApi> = {
|
||||
// the `Database` class itself
|
||||
sql: ['load', 'get'],
|
||||
store: ['load', 'getStore', 'LazyStore', 'Store'],
|
||||
stronghold: ['Location', 'Client', 'Store', 'Vault', 'Stronghold'],
|
||||
upload: ['download', 'upload', 'HttpMethod'],
|
||||
// the `WebSocket` class itself
|
||||
websocket: ['connect']
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
scratchDir
|
||||
} from '../helpers/index.js'
|
||||
|
||||
// The example derives the snapshot key from the password with argon2. Each
|
||||
// test works on its own snapshot file under the spec's scratch directory,
|
||||
// which the fs plugin cleans up; the snapshot path is resolved inside the page.
|
||||
|
||||
const dir = scratchDir('stronghold')
|
||||
const password = 'e2e-password'
|
||||
const clientName = 'e2e-client'
|
||||
|
||||
/** An arbitrary, valid BIP39 mnemonic, so derived keys are deterministic. */
|
||||
const mnemonic =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
|
||||
describePlugin('stronghold', () => {
|
||||
before(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
await api.fs.mkdir(dir, { baseDir, recursive: true })
|
||||
}, dir)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
}, dir)
|
||||
})
|
||||
|
||||
it('store records round-trip and persist in the snapshot', async () => {
|
||||
const result = await tauri(
|
||||
async (api, dir, password, clientName) => {
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
dir,
|
||||
'store.stronghold'
|
||||
)
|
||||
const value = Array.from(new TextEncoder().encode('top secret'))
|
||||
|
||||
const stronghold = await api.stronghold.Stronghold.load(path, password)
|
||||
const client = await stronghold.createClient(clientName)
|
||||
await client.getStore().insert('key', value)
|
||||
const sameSession = await client.getStore().get('key')
|
||||
await stronghold.save()
|
||||
await stronghold.unload()
|
||||
|
||||
const reopened = await api.stronghold.Stronghold.load(path, password)
|
||||
const store = (await reopened.loadClient(clientName)).getStore()
|
||||
const persisted = await store.get('key')
|
||||
const missing = await store.get('missing')
|
||||
await reopened.unload()
|
||||
|
||||
return {
|
||||
sameSession: sameSession && Array.from(sameSession),
|
||||
persisted: persisted && new TextDecoder().decode(persisted),
|
||||
missing,
|
||||
fileExists: await api.fs.exists(path)
|
||||
}
|
||||
},
|
||||
dir,
|
||||
password,
|
||||
clientName
|
||||
)
|
||||
expect(result.sameSession).toEqual(
|
||||
Array.from(new TextEncoder().encode('top secret'))
|
||||
)
|
||||
expect(result.persisted).toBe('top secret')
|
||||
expect(result.missing).toBeNull()
|
||||
expect(result.fileExists).toBe(true)
|
||||
})
|
||||
|
||||
it('store.remove returns the removed value', async () => {
|
||||
const result = await tauri(
|
||||
async (api, dir, password, clientName) => {
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
dir,
|
||||
'remove.stronghold'
|
||||
)
|
||||
const stronghold = await api.stronghold.Stronghold.load(path, password)
|
||||
const store = (await stronghold.createClient(clientName)).getStore()
|
||||
await store.insert('key', [1, 2, 3])
|
||||
const removed = await store.remove('key')
|
||||
const after = await store.get('key')
|
||||
await stronghold.unload()
|
||||
return { removed: removed && Array.from(removed), after }
|
||||
},
|
||||
dir,
|
||||
password,
|
||||
clientName
|
||||
)
|
||||
expect(result).toEqual({ removed: [1, 2, 3], after: null })
|
||||
})
|
||||
|
||||
it('a snapshot cannot be opened with the wrong password', async () => {
|
||||
const path = await tauri(
|
||||
async (api, dir, password, clientName) => {
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
dir,
|
||||
'password.stronghold'
|
||||
)
|
||||
const stronghold = await api.stronghold.Stronghold.load(path, password)
|
||||
await stronghold.createClient(clientName)
|
||||
await stronghold.unload()
|
||||
return path
|
||||
},
|
||||
dir,
|
||||
password,
|
||||
clientName
|
||||
)
|
||||
const error = await tauriError(
|
||||
(api, path) => api.stronghold.Stronghold.load(path, 'wrong-password'),
|
||||
path
|
||||
)
|
||||
expect(error).toMatch(/failed to decode\/decrypt/)
|
||||
})
|
||||
|
||||
it('loading a client that was never created is rejected', async () => {
|
||||
const error = await tauriError(
|
||||
async (api, dir, password) => {
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
dir,
|
||||
'no-client.stronghold'
|
||||
)
|
||||
const stronghold = await api.stronghold.Stronghold.load(path, password)
|
||||
try {
|
||||
await stronghold.loadClient('never-created')
|
||||
} finally {
|
||||
await stronghold.unload()
|
||||
}
|
||||
},
|
||||
dir,
|
||||
password
|
||||
)
|
||||
expect(error).toMatch(/error loading client data/)
|
||||
})
|
||||
|
||||
it('vault procedures derive keys and sign without exposing secrets', async () => {
|
||||
const result = await tauri(
|
||||
async (api, dir, password, clientName, mnemonic) => {
|
||||
const { Location } = api.stronghold
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
dir,
|
||||
'vault.stronghold'
|
||||
)
|
||||
const stronghold = await api.stronghold.Stronghold.load(path, password)
|
||||
const vault = (await stronghold.createClient(clientName)).getVault(
|
||||
'vault'
|
||||
)
|
||||
|
||||
// the same mnemonic recovered twice derives the same key
|
||||
const seedA = Location.generic('vault', 'seed-a')
|
||||
const seedB = Location.generic('vault', 'seed-b')
|
||||
await vault.recoverBIP39(mnemonic, seedA)
|
||||
await vault.recoverBIP39(mnemonic, seedB)
|
||||
// Ed25519 SLIP-10 only derives hardened indices (the high bit set).
|
||||
// (No named helper: the transpiler would wrap it in a `__name` call
|
||||
// that does not exist in the page.)
|
||||
const chain = [44, 4218, 0, 0, 0].map((i) => (i | 0x80000000) >>> 0)
|
||||
const otherChain = [44, 4218, 0, 0, 1].map(
|
||||
(i) => (i | 0x80000000) >>> 0
|
||||
)
|
||||
const keyA = Location.generic('vault', 'key-a')
|
||||
const keyB = Location.generic('vault', 'key-b')
|
||||
const keyOther = Location.generic('vault', 'key-other')
|
||||
await vault.deriveSLIP10(chain, 'Seed', seedA, keyA)
|
||||
await vault.deriveSLIP10(chain, 'Seed', seedB, keyB)
|
||||
await vault.deriveSLIP10(otherChain, 'Seed', seedA, keyOther)
|
||||
|
||||
const publicA = Array.from(await vault.getEd25519PublicKey(keyA))
|
||||
const publicB = Array.from(await vault.getEd25519PublicKey(keyB))
|
||||
const publicOther = Array.from(
|
||||
await vault.getEd25519PublicKey(keyOther)
|
||||
)
|
||||
const signature = Array.from(await vault.signEd25519(keyA, 'message'))
|
||||
const signatureAgain = Array.from(
|
||||
await vault.signEd25519(keyA, 'message')
|
||||
)
|
||||
|
||||
// a random seed and a raw secret can be stored and removed
|
||||
const random = Location.generic('vault', 'random')
|
||||
await vault.generateSLIP10Seed(random)
|
||||
await vault.insert('raw', [9, 9, 9])
|
||||
await vault.remove(Location.generic('vault', 'raw'))
|
||||
|
||||
// a generated mnemonic recovers the seed generateBIP39 stored
|
||||
const generatedSeed = Location.generic('vault', 'generated')
|
||||
const mnemonicBytes = await vault.generateBIP39(generatedSeed)
|
||||
const recoveredSeed = Location.generic('vault', 'recovered')
|
||||
await vault.recoverBIP39(
|
||||
new TextDecoder().decode(mnemonicBytes),
|
||||
recoveredSeed
|
||||
)
|
||||
const generatedKey = Location.generic('vault', 'generated-key')
|
||||
const recoveredKey = Location.generic('vault', 'recovered-key')
|
||||
await vault.deriveSLIP10(chain, 'Seed', generatedSeed, generatedKey)
|
||||
await vault.deriveSLIP10(chain, 'Seed', recoveredSeed, recoveredKey)
|
||||
const generatedPublic = Array.from(
|
||||
await vault.getEd25519PublicKey(generatedKey)
|
||||
)
|
||||
const recoveredPublic = Array.from(
|
||||
await vault.getEd25519PublicKey(recoveredKey)
|
||||
)
|
||||
|
||||
await stronghold.unload()
|
||||
return {
|
||||
publicA,
|
||||
publicB,
|
||||
publicOther,
|
||||
signature,
|
||||
signatureAgain,
|
||||
generatedPublic,
|
||||
recoveredPublic
|
||||
}
|
||||
},
|
||||
dir,
|
||||
password,
|
||||
clientName,
|
||||
mnemonic
|
||||
)
|
||||
expect(result.publicA).toHaveLength(32)
|
||||
expect(result.publicB).toEqual(result.publicA)
|
||||
expect(result.publicOther).not.toEqual(result.publicA)
|
||||
// Ed25519 signatures are 64 bytes and deterministic
|
||||
expect(result.signature).toHaveLength(64)
|
||||
expect(result.signatureAgain).toEqual(result.signature)
|
||||
expect(result.recoveredPublic).toEqual(result.generatedPublic)
|
||||
expect(result.generatedPublic).not.toEqual(result.publicA)
|
||||
})
|
||||
})
|
||||
@@ -303,33 +303,12 @@ function tauriCli(args: string[], env: NodeJS.ProcessEnv = {}): void {
|
||||
* Environment for the Android build.
|
||||
*
|
||||
* - No debug info: the suite needs a debug build (webview debugging follows
|
||||
* `debug_assertions`), but with the stronghold, sql and websocket plugins the
|
||||
* `debug_assertions`), but with the sql and websocket plugins the
|
||||
* debug info alone grows the APK past what a default emulator can install
|
||||
* ("not enough space").
|
||||
* - On a macOS host, `AR`/`RANLIB` point at the NDK's LLVM tools. Autotools-built
|
||||
* C dependencies (libsodium, through the stronghold plugin) otherwise fall back
|
||||
* to Apple's `ar`/`ranlib`, which silently produce an empty archive from the
|
||||
* Android (ELF) objects, and the app then fails to load its library with an
|
||||
* unresolved symbol. Linux hosts' GNU `ar` is fine.
|
||||
*/
|
||||
function androidBuildEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { CARGO_PROFILE_DEV_DEBUG: '0' }
|
||||
const ndk = process.env.NDK_HOME ?? process.env.ANDROID_NDK_HOME
|
||||
if (process.platform !== 'darwin' || !ndk) return env
|
||||
// the NDK only ships an x86_64 (Rosetta-compatible) macOS toolchain
|
||||
const bin = path.join(
|
||||
ndk,
|
||||
'toolchains',
|
||||
'llvm',
|
||||
'prebuilt',
|
||||
'darwin-x86_64',
|
||||
'bin'
|
||||
)
|
||||
return {
|
||||
...env,
|
||||
AR: path.join(bin, 'llvm-ar'),
|
||||
RANLIB: path.join(bin, 'llvm-ranlib')
|
||||
}
|
||||
return { CARGO_PROFILE_DEV_DEBUG: '0' }
|
||||
}
|
||||
|
||||
/** `adb` from the Android SDK, else whatever is on `PATH`. */
|
||||
|
||||
Generated
-6
@@ -219,9 +219,6 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/store
|
||||
'@tauri-apps/plugin-stronghold':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/stronghold
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/updater
|
||||
@@ -337,9 +334,6 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/store
|
||||
'@tauri-apps/plugin-stronghold':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/stronghold
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: workspace:*
|
||||
version: link:../../plugins/updater
|
||||
|
||||
Reference in New Issue
Block a user