feat(core): Implements core configuration file generation functionality

- Adds the bootArgs.ts utility module for generating and deleting configuration files
- Integrates configuration file generation and cleanup logic into spary.vue
- Integrates the tauri-plugin-fs plugin to support file system operations
- Adds a Linux system proxy configuration script
This commit is contained in:
2025-10-21 15:37:04 +08:00
parent 2b8f09b4b2
commit 462f70f35d
17 changed files with 731 additions and 56 deletions

1
.gitignore vendored
View File

@@ -58,3 +58,4 @@ Desktop.ini
coverage/
*.lcov
.junit/
/cores/

View File

@@ -13,6 +13,7 @@
"@fontsource/roboto": "5.2.7",
"@mdi/font": "7.4.47",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "~2",
"vue": "^3.5.21",

159
scripts/set_linux_proxy.sh Normal file
View File

@@ -0,0 +1,159 @@
#!/bin/bash
# fork from https://github.com/2dust/v2rayN/blob/899b3fc97bc5024cd514bdbc8626a5d3c98170a7/v2rayN/ServiceLib/Sample/proxy_set_linux_sh
# Function to set proxy for GNOME
set_gnome_proxy() {
local MODE=$1
local PROXY_IP=$2
local PROXY_PORT=$3
local IGNORE_HOSTS=$4
# Set the proxy mode
gsettings set org.gnome.system.proxy mode "$MODE"
if [ "$MODE" == "manual" ]; then
# List of protocols
local PROTOCOLS=("http" "https" "ftp" "socks")
# Loop through protocols to set the proxy
for PROTOCOL in "${PROTOCOLS[@]}"; do
gsettings set org.gnome.system.proxy.$PROTOCOL host "$PROXY_IP"
gsettings set org.gnome.system.proxy.$PROTOCOL port "$PROXY_PORT"
done
# Set ignored hosts
gsettings set org.gnome.system.proxy ignore-hosts "['$IGNORE_HOSTS']"
echo "GNOME: Manual proxy settings applied."
echo "Proxy IP: $PROXY_IP"
echo "Proxy Port: $PROXY_PORT"
echo "Ignored Hosts: $IGNORE_HOSTS"
elif [ "$MODE" == "none" ]; then
echo "GNOME: Proxy disabled."
fi
}
# Function to set proxy for KDE
set_kde_proxy() {
local MODE=$1
local PROXY_IP=$2
local PROXY_PORT=$3
local IGNORE_HOSTS=$4
# Determine the correct kwriteconfig command based on KDE_SESSION_VERSION
if [ "$KDE_SESSION_VERSION" == "6" ]; then
KWRITECONFIG="kwriteconfig6"
else
KWRITECONFIG="kwriteconfig5"
fi
# KDE uses kwriteconfig to modify proxy settings
if [ "$MODE" == "manual" ]; then
# Set proxy for all protocols
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key ProxyType 1
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key httpProxy "http://$PROXY_IP:$PROXY_PORT"
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key httpsProxy "http://$PROXY_IP:$PROXY_PORT"
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key ftpProxy "http://$PROXY_IP:$PROXY_PORT"
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key socksProxy "http://$PROXY_IP:$PROXY_PORT"
# Set ignored hosts
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key NoProxyFor "$IGNORE_HOSTS"
echo "KDE: Manual proxy settings applied."
echo "Proxy IP: $PROXY_IP"
echo "Proxy Port: $PROXY_PORT"
echo "Ignored Hosts: $IGNORE_HOSTS"
elif [ "$MODE" == "none" ]; then
# Disable proxy
$KWRITECONFIG --file kioslaverc --group "Proxy Settings" --key ProxyType 0
echo "KDE: Proxy disabled."
fi
# Apply changes by restarting KDE's network settings
dbus-send --type=signal /KIO/Scheduler org.kde.KIO.Scheduler.reparseSlaveConfiguration string:""
}
# Detect the current desktop environment
detect_desktop_environment() {
if [[ "$XDG_CURRENT_DESKTOP" == *"GNOME"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"GNOME"* ]]; then
echo "gnome"
return
fi
if [[ "$XDG_CURRENT_DESKTOP" == *"XFCE"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"XFCE"* ]]; then
echo "gnome"
return
fi
if [[ "$XDG_CURRENT_DESKTOP" == *"X-Cinnamon"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"cinnamon"* ]]; then
echo "gnome"
return
fi
if [[ "$XDG_CURRENT_DESKTOP" == *"UKUI"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"ukui"* ]]; then
echo "gnome"
return
fi
if [[ "$XDG_CURRENT_DESKTOP" == *"DDE"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"dde"* ]]; then
echo "gnome"
return
fi
if [[ "$XDG_CURRENT_DESKTOP" == *"MATE"* ]] || [[ "$XDG_SESSION_DESKTOP" == *"mate"* ]]; then
echo "gnome"
return
fi
local KDE_ENVIRONMENTS=("KDE" "plasma")
for ENV in "${KDE_ENVIRONMENTS[@]}"; do
if [ "$XDG_CURRENT_DESKTOP" == "$ENV" ] || [ "$XDG_SESSION_DESKTOP" == "$ENV" ]; then
echo "kde"
return
fi
done
# Fallback to GNOME method if CLI utility is available. This solves the
# proxy configuration issues on minimal installation systems, like setups
# with only window managers, that borrow some parts from big DEs.
if command -v gsettings >/dev/null 2>&1; then
echo "gnome"
return
fi
echo "unsupported"
}
# Main script logic
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <mode> [proxy_ip proxy_port ignore_hosts]"
echo " mode: 'none' or 'manual'"
echo " If mode is 'manual', provide proxy IP, port, and ignore hosts."
exit 1
fi
# Get the mode
MODE=$1
PROXY_IP=$2
PROXY_PORT=$3
IGNORE_HOSTS=$4
if ! [[ "$MODE" =~ ^(manual|none)$ ]]; then
echo "Invalid mode. Use 'none' or 'manual'." >&2
exit 1
fi
# Detect desktop environment
DE=$(detect_desktop_environment)
# Apply settings based on the desktop environment
if [ "$DE" == "gnome" ]; then
set_gnome_proxy "$MODE" "$PROXY_IP" "$PROXY_PORT" "$IGNORE_HOSTS"
elif [ "$DE" == "kde" ]; then
set_gnome_proxy "$MODE" "$PROXY_IP" "$PROXY_PORT" "$IGNORE_HOSTS"
set_kde_proxy "$MODE" "$PROXY_IP" "$PROXY_PORT" "$IGNORE_HOSTS"
else
echo "Unsupported desktop environment: $DE" >&2
exit 1
fi

View File

@@ -23,3 +23,4 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-fs = "2"

View File

@@ -9,6 +9,23 @@
"core:default",
"opener:default",
"sql:default",
"sql:allow-execute"
"sql:allow-execute",
"fs:default",
{
"identifier": "fs:allow-write-text-file",
"allow": [
{
"path": "$APPCONFIG/*"
}
]
},
{
"identifier": "fs:allow-remove",
"allow": [
{
"path": "$APPCONFIG/*"
}
]
}
]
}

3
src-tauri/src/cores.rs Normal file
View File

@@ -0,0 +1,3 @@
enum Core{
XRAY(String),
}

130
src-tauri/src/exe.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
pub struct Exe {
path: PathBuf,
args: Vec<String>,
child: Arc<Mutex<Option<Child>>>,
pub pid: Arc<Mutex<Option<u32>>>,
}
impl Exe {
pub fn new<P: Into<PathBuf>, S: Into<String>>(path: P, args: Vec<S>) -> Self {
Self {
path: path.into(),
args: args.into_iter().map(|s| s.into()).collect(),
child: Arc::new(Mutex::new(None)),
pid: Arc::new(Mutex::new(None)),
}
}
pub fn start<F, E, X>(
&mut self,
mut on_stdout: F,
mut on_stderr: E,
mut on_exit: X,
) -> std::io::Result<()>
where
F: FnMut(String) + Send + 'static,
E: FnMut(String) + Send + 'static,
X: FnMut(i32) + Send + 'static,
{
let mut cmd = Command::new(&self.path);
cmd.args(&self.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
// ✅ 保存 PID
let pid = child.id();
{
*self.pid.lock().unwrap() = Some(pid);
}
// ✅ 异步读取 stdout
if let Some(out) = child.stdout.take() {
let mut reader = BufReader::new(out);
let mut line = String::new();
thread::spawn(move || {
while let Ok(n) = reader.read_line(&mut line) {
if n == 0 {
break;
}
on_stdout(line.clone());
line.clear();
}
});
}
if let Some(err) = child.stderr.take() {
let mut reader = BufReader::new(err);
let mut line = String::new();
thread::spawn(move || {
while let Ok(n) = reader.read_line(&mut line) {
if n == 0 {
break;
}
on_stderr(line.clone());
line.clear();
}
});
}
let child_arc = self.child.clone();
*child_arc.lock().unwrap() = Some(child);
let pid_clone = self.pid.clone();
thread::spawn(move || {
if let Some(mut c) = child_arc.lock().unwrap().take() {
match c.wait() {
Ok(status) => {
let code = status.code().unwrap_or(-1);
*pid_clone.lock().unwrap() = None;
on_exit(code);
}
Err(e) => {
eprintln!("Error waiting for process: {e}");
*pid_clone.lock().unwrap() = None;
on_exit(-1);
}
}
}
});
Ok(())
}
pub fn kill(&self) -> std::io::Result<()> {
let mut guard = self.child.lock().unwrap();
if let Some(child) = guard.as_mut() {
child.kill()?;
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"No running process",
))
}
}
pub fn is_alive(&self) -> bool {
let mut guard = self.child.lock().unwrap();
if let Some(child) = guard.as_mut() {
match child.try_wait() {
Ok(Some(_)) => false, // 已退出
Ok(None) => true, // 仍在运行
Err(_) => false,
}
} else {
false
}
}
pub fn get_pid(&self) -> Option<u32> {
*self.pid.lock().unwrap()
}
}

View File

@@ -3,8 +3,10 @@
use crate::group::add_group;
use crate::spary::spary_switch;
use tauri_plugin_sql::{Migration, MigrationKind};
mod exe;
mod group;
mod spary;
mod cores;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -21,8 +23,15 @@ pub fn run() {
sql: "CREATE TABLE node(id INTEGER PRIMARY KEY AUTOINCREMENT, alias VARCHAR(60) NOT NULL,arguments JSON NOT NULL default '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, group_id INTEGER NOT NULL)",
kind: MigrationKind::Up,
},
Migration{
version:3,
description:"add default group",
sql:"INSERT INTO `group`(name) VALUES('default')",
kind:MigrationKind::Up,
}
];
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:spary.db", migrations)

View File

@@ -1,51 +1,10 @@
<template>
<v-app>
<notification-provider />
<notification-provider/>
<v-main>
<v-card class="fill-height">
<v-layout class="fill-height">
<v-navigation-drawer
expand-on-hover
permanent
rail
v-model="drawer"
>
<v-list>
<v-list-item
prepend-avatar="/src/assets/logo.svg"
subtitle=""
:title="$t('app.title')"
></v-list-item>
</v-list>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
prepend-icon="mdi-just-nothing"
title="🌊"
value="spary"
@click="router.push('/')"
></v-list-item>
<v-list-item
prepend-icon="mdi-airport"
:title="$t('app.nodes')"
value="nodes"
@click="router.push('/nodes')"
></v-list-item>
<v-list-item
prepend-icon="mdi-cog"
:title="$t('app.settings')"
value="settings"
@click="router.push('/settings')"
>
<template v-slot:append>
<language-switcher />
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
<main-drawer/>
<v-main style="height: 100vh">
<router-view/>
</v-main>
@@ -56,13 +15,9 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LanguageSwitcher from './components/LanguageSwitcher.vue'
import {useI18n} from 'vue-i18n'
import notificationProvider from './components/notify/notificationProvider.vue'
import MainDrawer from "@/components/index/mainDrawer.vue";
const drawer = ref(true)
const router = useRouter()
useI18n()
</script>

1
src/components.d.ts vendored
View File

@@ -11,6 +11,7 @@ declare module 'vue' {
AddGroup: typeof import('./components/nodeEdit/addGroup.vue')['default']
AddNode: typeof import('./components/nodeEdit/addNode.vue')['default']
LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default']
MainDrawer: typeof import('./components/index/mainDrawer.vue')['default']
NodeList: typeof import('./components/nodeEdit/nodeList.vue')['default']
NodesFloatButton: typeof import('./components/nodeEdit/nodesFloatButton.vue')['default']
NotificationProvider: typeof import('./components/notify/notificationProvider.vue')['default']

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import {ref} from 'vue'
import {useRouter} from 'vue-router'
import LanguageSwitcher from '../LanguageSwitcher.vue'
import {useI18n} from "vue-i18n";
const drawer = ref(true)
const router = useRouter()
useI18n()
</script>
<template>
<v-navigation-drawer
expand-on-hover
permanent
rail
v-model="drawer"
>
<v-list>
<v-list-item
prepend-avatar="/src/assets/logo.svg"
subtitle=""
:title="$t('app.title')"
></v-list-item>
</v-list>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
prepend-icon="mdi-home"
:title="$t('app.mainPage')"
value="spary"
@click="router.push('/')"
></v-list-item>
<v-list-item
prepend-icon="mdi-airport"
:title="$t('app.nodes')"
value="nodes"
@click="router.push('/nodes')"
></v-list-item>
<v-list-item
prepend-icon="mdi-cog"
:title="$t('app.settings')"
value="settings"
@click="router.push('/settings')"
>
<template v-slot:append>
<language-switcher/>
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<style scoped>
</style>

View File

@@ -2,9 +2,9 @@
<v-container class="fill-height d-flex align-center justify-center">
<div>
<v-switch
:label="functionStatus === 'On' ? $t('spary.functionStatus.on') : $t('spary.functionStatus.off')"
:model-value="functionStatus === 'On'"
@update:model-value="toggleFunctionStatus"
:label="functionStatus === 'On' ? $t('spary.functionStatus.on') : $t('spary.functionStatus.off')"
:model-value="functionStatus === 'On'"
@update:model-value="toggleFunctionStatus"
></v-switch>
</div>
</v-container>
@@ -13,14 +13,22 @@
<script setup lang="ts">
import {ref} from "vue";
import {invoke} from "@tauri-apps/api/core";
import {genConfigFile, removeConfigFile} from "@/utils/bootArgs.ts";
const functionStatus = ref<String>("Off");
function toggleFunctionStatus() {
functionStatus.value = functionStatus.value === "Off" ? "On" : "Off";
spary_switch(functionStatus.value === "On")
if (functionStatus.value === "On") {
genConfigFile()
} else {
removeConfigFile()
}
}
async function spary_switch(status:boolean){
await invoke("spary_switch",{status})
async function spary_switch(status: boolean) {
await invoke("spary_switch", {status})
}
</script>

View File

@@ -1,5 +1,6 @@
{
"app": {
"mainPage": "Home",
"title": "Spary",
"nodes": "Nodes",
"settings": "Settings",

View File

@@ -1,5 +1,6 @@
{
"app": {
"mainPage": "主页",
"title": "Spary",
"nodes": "节点",
"settings": "设置",

View File

@@ -10,7 +10,7 @@ const messages = {
const i18n = createI18n({
legacy: false, // Use composition API mode
locale: 'en', // Default locale
fallbackLocale: 'en', // Fallback locale
fallbackLocale: 'zh', // Fallback locale
messages
})

321
src/utils/bootArgs.ts Normal file
View File

@@ -0,0 +1,321 @@
import {writeTextFile, BaseDirectory, remove} from '@tauri-apps/plugin-fs';
export async function removeConfigFile(){
try {
await remove('config.json', { baseDir: BaseDirectory.AppConfig });
} catch (error) {
// ignore
console.warn(error);
}
}
export async function genConfigFile() {
await removeConfigFile()
const configContent = `
{
"log": {
"loglevel": "warning"
},
"dns": {
"hosts": {
"dns.google": [
"8.8.8.8",
"8.8.4.4",
"2001:4860:4860::8888",
"2001:4860:4860::8844"
],
"dns.alidns.com": [
"223.5.5.5",
"223.6.6.6",
"2400:3200::1",
"2400:3200:baba::1"
],
"one.one.one.one": [
"1.1.1.1",
"1.0.0.1",
"2606:4700:4700::1111",
"2606:4700:4700::1001"
],
"1dot1dot1dot1.cloudflare-dns.com": [
"1.1.1.1",
"1.0.0.1",
"2606:4700:4700::1111",
"2606:4700:4700::1001"
],
"cloudflare-dns.com": [
"104.16.249.249",
"104.16.248.249",
"2606:4700::6810:f8f9",
"2606:4700::6810:f9f9"
],
"dns.cloudflare.com": [
"104.16.132.229",
"104.16.133.229",
"2606:4700::6810:84e5",
"2606:4700::6810:85e5"
],
"dot.pub": [
"1.12.12.12",
"120.53.53.53"
],
"doh.pub": [
"1.12.12.12",
"120.53.53.53"
],
"dns.quad9.net": [
"9.9.9.9",
"149.112.112.112",
"2620:fe::fe",
"2620:fe::9"
],
"dns.yandex.net": [
"77.88.8.8",
"77.88.8.1",
"2a02:6b8::feed:0ff",
"2a02:6b8:0:1::feed:0ff"
],
"dns.sb": [
"185.222.222.222",
"2a09::"
],
"dns.umbrella.com": [
"208.67.220.220",
"208.67.222.222",
"2620:119:35::35",
"2620:119:53::53"
],
"dns.sse.cisco.com": [
"208.67.220.220",
"208.67.222.222",
"2620:119:35::35",
"2620:119:53::53"
],
"engage.cloudflareclient.com": [
"162.159.192.1",
"2606:4700:d0::a29f:c001"
]
},
"servers": [
{
"address": "https://cloudflare-dns.com/dns-query",
"domains": [
"domain:googleapis.cn",
"domain:gstatic.com"
],
"skipFallback": true
},
{
"address": "https://dns.alidns.com/dns-query",
"domains": [
"domain:alidns.com",
"domain:doh.pub",
"domain:dot.pub",
"domain:360.cn",
"domain:onedns.net",
"pioneer.kakakuai.top"
],
"skipFallback": true
},
{
"address": "https://dns.alidns.com/dns-query",
"domains": [
"geosite:private",
"geosite:cn"
],
"skipFallback": true
},
"https://cloudflare-dns.com/dns-query"
]
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"listen": "0.0.0.0",
"protocol": "mixed",
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
],
"routeOnly": false
},
"settings": {
"auth": "noauth",
"udp": true,
"allowTransparent": false
}
},
{
"tag": "api",
"port": 10812,
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": {
"address": "127.0.0.1"
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "trojan",
"settings": {
"servers": [
{
"address": "pioneer.kakakuai.top",
"method": "",
"ota": false,
"password": "0FUz1P7obM",
"port": 443,
"level": 1
}
]
},
"streamSettings": {
"network": "ws",
"security": "tls",
"tlsSettings": {
"allowInsecure": false
},
"wsSettings": {
"path": "/dsf",
"headers": {}
}
},
"mux": {
"enabled": false,
"concurrency": -1
}
},
{
"tag": "direct",
"protocol": "freedom"
},
{
"tag": "block",
"protocol": "blackhole"
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"inboundTag": [
"api"
],
"outboundTag": "api"
},
{
"type": "field",
"outboundTag": "proxy",
"domain": [
"domain:googleapis.cn",
"domain:gstatic.com"
]
},
{
"type": "field",
"port": "443",
"network": "udp",
"outboundTag": "block"
},
{
"type": "field",
"outboundTag": "direct",
"ip": [
"geoip:private"
]
},
{
"type": "field",
"outboundTag": "direct",
"domain": [
"geosite:private"
]
},
{
"type": "field",
"outboundTag": "direct",
"ip": [
"223.5.5.5",
"223.6.6.6",
"2400:3200::1",
"2400:3200:baba::1",
"119.29.29.29",
"1.12.12.12",
"120.53.53.53",
"2402:4e00::",
"2402:4e00:1::",
"180.76.76.76",
"2400:da00::6666",
"114.114.114.114",
"114.114.115.115",
"114.114.114.119",
"114.114.115.119",
"114.114.114.110",
"114.114.115.110",
"180.184.1.1",
"180.184.2.2",
"101.226.4.6",
"218.30.118.6",
"123.125.81.6",
"140.207.198.6",
"1.2.4.8",
"210.2.4.8",
"52.80.66.66",
"117.50.22.22",
"2400:7fc0:849e:200::4",
"2404:c2c0:85d8:901::4",
"117.50.10.10",
"52.80.52.52",
"2400:7fc0:849e:200::8",
"2404:c2c0:85d8:901::8",
"117.50.60.30",
"52.80.60.30"
]
},
{
"type": "field",
"outboundTag": "direct",
"domain": [
"domain:alidns.com",
"domain:doh.pub",
"domain:dot.pub",
"domain:360.cn",
"domain:onedns.net"
]
},
{
"type": "field",
"outboundTag": "direct",
"ip": [
"geoip:cn"
]
},
{
"type": "field",
"outboundTag": "direct",
"domain": [
"geosite:cn"
]
}
]
},
"metrics": {
"tag": "api"
},
"policy": {
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"stats": {}
}
`
const finalContent = JSON.stringify(JSON.parse(configContent), null, 4);
await writeTextFile('config.json', finalContent, {
baseDir: BaseDirectory.AppConfig,
});
}

View File

@@ -683,6 +683,13 @@
"@tauri-apps/cli-win32-ia32-msvc" "2.8.4"
"@tauri-apps/cli-win32-x64-msvc" "2.8.4"
"@tauri-apps/plugin-fs@~2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.4.2.tgz#cce4ea16dc3614f7a476988457ec4cf0024d81f6"
integrity sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-opener@^2":
version "2.5.0"
resolved "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.0.tgz"