mirror of
https://github.com/safing/portmaster
synced 2025-04-08 13:09:11 +00:00
Migrate tauri from portmaster-ui to desktop/tauri. Update build system
This commit is contained in:
parent
ac23ce32a1
commit
d524bce166
35 changed files with 10960 additions and 42 deletions
.angulardoc.json.earthlyignore.gitignore
.vscode
Earthfiledesktop
angular
tauri
.gitkeepassets
src-tauri
.gitignoreCargo.lockCargo.tomlbuild.rs
src
tauri.conf.json
4
.angulardoc.json
Normal file
4
.angulardoc.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"repoId": "8f466ce7-4b75-4048-8b8a-cad5bf173aa0",
|
||||||
|
"lastSync": 0
|
||||||
|
}
|
|
@ -12,4 +12,14 @@ desktop/angular/.angular
|
||||||
|
|
||||||
# Assets are ignored here because the symlink wouldn't work in
|
# Assets are ignored here because the symlink wouldn't work in
|
||||||
# the buildkit container so we copy the assets directly in Earthfile.
|
# the buildkit container so we copy the assets directly in Earthfile.
|
||||||
desktop/angular/assets
|
desktop/angular/assets
|
||||||
|
|
||||||
|
|
||||||
|
desktop/tauri/src-tauri/target
|
||||||
|
.gitignore
|
||||||
|
AUTHORS
|
||||||
|
CODE_OF_CONDUCT.md
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
TESTING.md
|
||||||
|
TRADEMARKS
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -1,12 +1,6 @@
|
||||||
# Compiled binaries
|
# Compiled binaries
|
||||||
portmaster
|
*.exe
|
||||||
portmaster.exe
|
dist/
|
||||||
dnsonly
|
|
||||||
dnsonly.exe
|
|
||||||
main
|
|
||||||
main.exe
|
|
||||||
integrationtest
|
|
||||||
integrationtest.exe
|
|
||||||
|
|
||||||
# Dist dir
|
# Dist dir
|
||||||
dist
|
dist
|
||||||
|
|
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
261
Earthfile
261
Earthfile
|
@ -1,10 +1,23 @@
|
||||||
VERSION --arg-scope-and-set 0.8
|
VERSION --arg-scope-and-set --global-cache 0.8
|
||||||
|
|
||||||
ARG --global go_version = 1.21
|
ARG --global go_version = 1.21
|
||||||
ARG --global distro = alpine3.18
|
ARG --global distro = alpine3.18
|
||||||
ARG --global node_version = 18
|
ARG --global node_version = 18
|
||||||
ARG --global outputDir = "./dist"
|
ARG --global outputDir = "./dist"
|
||||||
|
|
||||||
|
# The list of rust targets we support. They will be automatically converted
|
||||||
|
# to GOOS, GOARCH and GOARM when building go binaries. See the +RUST_TO_GO_ARCH_STRING
|
||||||
|
# helper method at the bottom of the file.
|
||||||
|
ARG --global architectures = "x86_64-unknown-linux-gnu" \
|
||||||
|
"aarch64-unknown-linux-gnu" \
|
||||||
|
"armv7-unknown-linux-gnueabihf" \
|
||||||
|
"arm-unknown-linux-gnueabi" \
|
||||||
|
"x86_64-pc-windows-gnu"
|
||||||
|
|
||||||
|
# Import the earthly rust lib since it already provides some useful
|
||||||
|
# build-targets and methods to initialize the rust toolchain.
|
||||||
|
IMPORT github.com/earthly/lib/rust:3.0.2 AS rust
|
||||||
|
|
||||||
go-deps:
|
go-deps:
|
||||||
FROM golang:${go_version}-${distro}
|
FROM golang:${go_version}-${distro}
|
||||||
WORKDIR /go-workdir
|
WORKDIR /go-workdir
|
||||||
|
@ -24,7 +37,6 @@ go-deps:
|
||||||
COPY go.sum .
|
COPY go.sum .
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
|
||||||
go-base:
|
go-base:
|
||||||
FROM +go-deps
|
FROM +go-deps
|
||||||
|
|
||||||
|
@ -42,6 +54,15 @@ go-base:
|
||||||
# ./assets
|
# ./assets
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
|
|
||||||
|
# updates all go dependencies and runs go mod tidy, saving go.mod and go.sum locally.
|
||||||
|
update-go-deps:
|
||||||
|
FROM +go-base
|
||||||
|
|
||||||
|
RUN go get -u ./..
|
||||||
|
RUN go mod tidy
|
||||||
|
SAVE ARTIFACT go.mod AS LOCAL go.mod
|
||||||
|
SAVE ARTIFACT --if-exists go.sum AS LOCAL go.sum
|
||||||
|
|
||||||
# mod-tidy runs 'go mod tidy', saving go.mod and go.sum locally.
|
# mod-tidy runs 'go mod tidy', saving go.mod and go.sum locally.
|
||||||
mod-tidy:
|
mod-tidy:
|
||||||
FROM +go-base
|
FROM +go-base
|
||||||
|
@ -61,6 +82,9 @@ build-go:
|
||||||
ARG GOARM
|
ARG GOARM
|
||||||
ARG CMDS=portmaster-start portmaster-core hub notifier
|
ARG CMDS=portmaster-start portmaster-core hub notifier
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
DO +GET_VERSION
|
||||||
|
|
||||||
CACHE --sharing shared "$GOCACHE"
|
CACHE --sharing shared "$GOCACHE"
|
||||||
CACHE --sharing shared "$GOMODCACHE"
|
CACHE --sharing shared "$GOMODCACHE"
|
||||||
|
|
||||||
|
@ -73,20 +97,17 @@ build-go:
|
||||||
|
|
||||||
# Build all go binaries from the specified in CMDS
|
# Build all go binaries from the specified in CMDS
|
||||||
FOR bin IN $CMDS
|
FOR bin IN $CMDS
|
||||||
RUN go build -o "/tmp/build/" ./cmds/${bin}
|
RUN go build -o "/tmp/build/" ./cmds/${bin}
|
||||||
END
|
END
|
||||||
|
|
||||||
LET NAME = ""
|
|
||||||
|
|
||||||
FOR bin IN $(ls -1 "/tmp/build/")
|
FOR bin IN $(ls -1 "/tmp/build/")
|
||||||
SET NAME = "${outputDir}/${GOOS}_${GOARCH}/${bin}"
|
DO +GO_ARCH_STRING --goos="${GOOS}" --goarch="${GOARCH}" --goarm="${GOARM}"
|
||||||
IF [ "${GOARM}" != "" ]
|
|
||||||
SET NAME = "${outputDir}/${GOOS}_${GOARCH}v${GOARM}/${bin}"
|
|
||||||
END
|
|
||||||
|
|
||||||
SAVE ARTIFACT "/tmp/build/${bin}" AS LOCAL "${NAME}"
|
SAVE ARTIFACT "/tmp/build/${bin}" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/${bin}"
|
||||||
END
|
END
|
||||||
|
|
||||||
|
SAVE ARTIFACT "/tmp/build/" ./output
|
||||||
|
|
||||||
# Test one or more go packages.
|
# Test one or more go packages.
|
||||||
# Run `earthly +test-go` to test all packages
|
# Run `earthly +test-go` to test all packages
|
||||||
# Run `earthly +test-go --PKG="service/firewall"` to only test a specific package.
|
# Run `earthly +test-go --PKG="service/firewall"` to only test a specific package.
|
||||||
|
@ -108,29 +129,19 @@ test-go:
|
||||||
END
|
END
|
||||||
|
|
||||||
test-go-all-platforms:
|
test-go-all-platforms:
|
||||||
# Linux platforms:
|
LOCALLY
|
||||||
BUILD +test-go --GOARCH=amd64 --GOOS=linux
|
FOR arch IN ${architectures}
|
||||||
BUILD +test-go --GOARCH=arm64 --GOOS=linux
|
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${arch}"
|
||||||
BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=5
|
BUILD +test-go --GOARCH="${GOARCH}" --GOOS="${GOOS}" --GOARM="${GOARM}"
|
||||||
BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=6
|
END
|
||||||
BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=7
|
|
||||||
|
|
||||||
# Windows platforms:
|
|
||||||
BUILD +test-go --GOARCH=amd64 --GOOS=windows
|
|
||||||
BUILD +test-go --GOARCH=arm64 --GOOS=windows
|
|
||||||
|
|
||||||
# Builds portmaster-start, portmaster-core, hub and notifier for all supported platforms
|
# Builds portmaster-start, portmaster-core, hub and notifier for all supported platforms
|
||||||
build-go-release:
|
build-go-release:
|
||||||
# Linux platforms:
|
LOCALLY
|
||||||
BUILD +build-go --GOARCH=amd64 --GOOS=linux
|
FOR arch IN ${architectures}
|
||||||
BUILD +build-go --GOARCH=arm64 --GOOS=linux
|
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${arch}"
|
||||||
BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=5
|
BUILD +build-go --GOARCH="${GOARCH}" --GOOS="${GOOS}" --GOARM="${GOARM}"
|
||||||
BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=6
|
END
|
||||||
BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=7
|
|
||||||
|
|
||||||
# Windows platforms:
|
|
||||||
BUILD +build-go --GOARCH=amd64 --GOOS=windows
|
|
||||||
BUILD +build-go --GOARCH=arm64 --GOOS=windows
|
|
||||||
|
|
||||||
# Builds all binaries from the cmds/ folder for linux/windows AMD64
|
# Builds all binaries from the cmds/ folder for linux/windows AMD64
|
||||||
# Most utility binaries are never needed on other platforms.
|
# Most utility binaries are never needed on other platforms.
|
||||||
|
@ -187,13 +198,199 @@ angular-project:
|
||||||
# Build the angular projects (portmaster-UI and tauri-builtin) in production mode
|
# Build the angular projects (portmaster-UI and tauri-builtin) in production mode
|
||||||
angular-release:
|
angular-release:
|
||||||
BUILD +angular-project --project=portmaster --dist=./dist --configuration=production --baseHref=/ui/modules/portmaster
|
BUILD +angular-project --project=portmaster --dist=./dist --configuration=production --baseHref=/ui/modules/portmaster
|
||||||
BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref="/"
|
|
||||||
|
|
||||||
# Build the angular projects (portmaster-UI and tauri-builtin) in dev mode
|
# Build the angular projects (portmaster-UI and tauri-builtin) in dev mode
|
||||||
angular-dev:
|
angular-dev:
|
||||||
BUILD +angular-project --project=portmaster --dist=./dist --configuration=development --baseHref=/ui/modules/portmaster
|
BUILD +angular-project --project=portmaster --dist=./dist --configuration=development --baseHref=/ui/modules/portmaster
|
||||||
BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=development --baseHref="/"
|
|
||||||
|
# A base target for rust to prepare the build container
|
||||||
|
rust-base:
|
||||||
|
FROM rust:1.76-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update -qq
|
||||||
|
RUN apt-get install --no-install-recommends -qq \
|
||||||
|
autoconf \
|
||||||
|
autotools-dev \
|
||||||
|
libtool-bin \
|
||||||
|
clang \
|
||||||
|
cmake \
|
||||||
|
bsdmainutils \
|
||||||
|
g++-mingw-w64-x86-64 \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
gcc-arm-none-eabi \
|
||||||
|
gcc-arm-linux-gnueabi \
|
||||||
|
gcc-arm-linux-gnueabihf \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
libssl-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev
|
||||||
|
|
||||||
|
# Add some required rustup components
|
||||||
|
RUN rustup component add clippy
|
||||||
|
RUN rustup component add rustfmt
|
||||||
|
|
||||||
|
# Install toolchains and targets
|
||||||
|
FOR arch IN ${architectures}
|
||||||
|
RUN rustup target add ${arch}
|
||||||
|
END
|
||||||
|
|
||||||
|
DO rust+INIT --keep_fingerprints=true
|
||||||
|
|
||||||
|
# For now we need tauri-cli 1.5 for bulding
|
||||||
|
DO rust+CARGO --args="install tauri-cli --version ^1.5.11"
|
||||||
|
|
||||||
|
tauri-src:
|
||||||
|
FROM +rust-base
|
||||||
|
|
||||||
|
WORKDIR /app/tauri
|
||||||
|
|
||||||
|
# --keep-ts is necessary to ensure that the timestamps of the source files
|
||||||
|
# are preserved such that Rust's incremental compilation works correctly.
|
||||||
|
COPY --keep-ts ./desktop/tauri/ .
|
||||||
|
COPY assets/data ./assets
|
||||||
|
COPY (+angular-project/dist/tauri-builtin --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref="/") ./../angular/dist/tauri-builtin
|
||||||
|
|
||||||
|
build-tauri:
|
||||||
|
FROM +tauri-src
|
||||||
|
|
||||||
|
ARG --required target
|
||||||
|
ARG output="release/[^\./]+"
|
||||||
|
ARG bundle="none"
|
||||||
|
|
||||||
|
# if we want tauri to create the installer bundles we also need to provide all external binaries
|
||||||
|
# we need to do some magic here because tauri expects the binaries to include the rust target tripple.
|
||||||
|
# We already knwo that triple because it's a required argument. From that triple, we use +RUST_TO_GO_ARCH_STRING
|
||||||
|
# function from below to parse the triple and guess wich GOOS and GOARCH we need.
|
||||||
|
IF [ "${bundle}" != "none" ]
|
||||||
|
RUN mkdir /tmp/gobuild
|
||||||
|
RUN mkdir ./binaries
|
||||||
|
|
||||||
|
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}"
|
||||||
|
RUN echo "GOOS=${GOOS} GOARCH=${GOARCH} GOARM=${GOARM} GO_ARCH_STRING=${GO_ARCH_STRING}"
|
||||||
|
|
||||||
|
COPY (+build-go/output --GOOS="${GOOS}" --CMDS="portmaster-start portmaster-core" --GOARCH="${GOARCH}" --GOARM="${GOARM}") /tmp/gobuild
|
||||||
|
|
||||||
|
LET dest=""
|
||||||
|
FOR bin IN $(ls /tmp/gobuild)
|
||||||
|
SET dest="./binaries/${bin}-${target}"
|
||||||
|
|
||||||
|
IF [ -z "${bin##*.exe}" ]
|
||||||
|
SET dest = "./binaries/${bin%.*}-${target}.exe"
|
||||||
|
END
|
||||||
|
|
||||||
|
RUN echo "Copying ${bin} to ${dest}"
|
||||||
|
RUN cp "/tmp/gobuild/${bin}" "${dest}"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
# The following is exected to work but doesn't. for whatever reason cargo-sweep errors out on the windows-toolchain.
|
||||||
|
#
|
||||||
|
# DO rust+CARGO --args="tauri build --bundles none --ci --target=${target}" --output="release/[^/\.]+"
|
||||||
|
#
|
||||||
|
# For, now, we just directly mount the rust target cache and call cargo ourself.
|
||||||
|
|
||||||
|
DO rust+SET_CACHE_MOUNTS_ENV
|
||||||
|
RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo tauri build --bundles "${bundle}" --ci --target="${target}"
|
||||||
|
DO rust+COPY_OUTPUT --output="${output}"
|
||||||
|
|
||||||
|
RUN ls target
|
||||||
|
|
||||||
|
tauri-release:
|
||||||
|
LOCALLY
|
||||||
|
|
||||||
|
ARG bundle="none"
|
||||||
|
|
||||||
|
FOR arch IN ${architectures}
|
||||||
|
BUILD +build-tauri --target="${arch}" --bundle="${bundle}"
|
||||||
|
END
|
||||||
|
|
||||||
release:
|
release:
|
||||||
BUILD +build-go-release
|
BUILD +build-go-release
|
||||||
BUILD +angular-release
|
BUILD +angular-release
|
||||||
|
|
||||||
|
|
||||||
|
# Takes GOOS, GOARCH and optionally GOARM and creates a string representation for file-names.
|
||||||
|
# in the form of ${GOOS}_{GOARCH} if GOARM is empty, otherwise ${GOOS}_${GOARCH}v${GOARM}.
|
||||||
|
# Thats the same format as expected and served by our update server.
|
||||||
|
#
|
||||||
|
# The result is available as GO_ARCH_STRING environment variable in the build context.
|
||||||
|
GO_ARCH_STRING:
|
||||||
|
FUNCTION
|
||||||
|
ARG --required goos
|
||||||
|
ARG --required goarch
|
||||||
|
ARG goarm
|
||||||
|
|
||||||
|
LET result = "${goos}_${goarch}"
|
||||||
|
IF [ "${goarm}" != "" ]
|
||||||
|
SET result = "${goos}_${goarch}v${goarm}"
|
||||||
|
END
|
||||||
|
|
||||||
|
ENV GO_ARCH_STRING="${result}"
|
||||||
|
|
||||||
|
# Takes a rust target (--rustTarget) and extracts architecture and OS and arm version
|
||||||
|
# and finally calls GO_ARCH_STRING.
|
||||||
|
#
|
||||||
|
# The result is available as GO_ARCH_STRING environment variable in the build context.
|
||||||
|
# It also exports GOOS, GOARCH and GOARM environment variables.
|
||||||
|
RUST_TO_GO_ARCH_STRING:
|
||||||
|
FUNCTION
|
||||||
|
ARG --required rustTarget
|
||||||
|
|
||||||
|
LET goos=""
|
||||||
|
IF [ -z "${rustTarget##*linux*}" ]
|
||||||
|
SET goos="linux"
|
||||||
|
ELSE
|
||||||
|
SET goos="windows"
|
||||||
|
END
|
||||||
|
|
||||||
|
|
||||||
|
LET goarch=""
|
||||||
|
LET goarm=""
|
||||||
|
|
||||||
|
IF [ -z "${rustTarget##*x86_64*}" ]
|
||||||
|
SET goarch="amd64"
|
||||||
|
ELSE IF [ -z "${rustTarget##*arm*}" ]
|
||||||
|
SET goarch="arm"
|
||||||
|
SET goarm="6"
|
||||||
|
|
||||||
|
IF [ -z "${rustTarget##*v7*}" ]
|
||||||
|
SET goarm="7"
|
||||||
|
END
|
||||||
|
ELSE IF [ -z "${rustTarget##*aarch64*}" ]
|
||||||
|
SET goarch="arm64"
|
||||||
|
ELSE
|
||||||
|
RUN echo "GOARCH not detected"; \
|
||||||
|
exit 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
ENV GOOS="${goos}"
|
||||||
|
ENV GOARCH="${goarch}"
|
||||||
|
ENV GOARM="${goarm}"
|
||||||
|
|
||||||
|
DO +GO_ARCH_STRING --goos="${goos}" --goarch="${goarch}" --goarm="${goarm}"
|
||||||
|
|
||||||
|
GET_VERSION:
|
||||||
|
FUNCTION
|
||||||
|
LOCALLY
|
||||||
|
|
||||||
|
LET VERSION=$(git tag --points-at)
|
||||||
|
IF [ -z "${VERSION}"]
|
||||||
|
SET VERSION=$(git describe --tags --abbrev=0)§dev§build
|
||||||
|
ELSE IF ! git diff --quite
|
||||||
|
SET VERSION="${VERSION}§dev§build"
|
||||||
|
END
|
||||||
|
|
||||||
|
RUN echo "Version is ${VERSION}"
|
||||||
|
ENV VERSION="${VERSION}"
|
||||||
|
|
||||||
|
test:
|
||||||
|
LOCALLY
|
||||||
|
|
||||||
|
DO +GET_VERSION
|
|
@ -15,7 +15,7 @@
|
||||||
"chrome-extension": "NODE_ENV=production ng build --configuration production portmaster-chrome-extension",
|
"chrome-extension": "NODE_ENV=production ng build --configuration production portmaster-chrome-extension",
|
||||||
"chrome-extension:dev": "ng build --configuration development portmaster-chrome-extension --watch",
|
"chrome-extension:dev": "ng build --configuration development portmaster-chrome-extension --watch",
|
||||||
"build": "npm run build-libs && NODE_ENV=production ng build --configuration production --base-href /ui/modules/portmaster/",
|
"build": "npm run build-libs && NODE_ENV=production ng build --configuration production --base-href /ui/modules/portmaster/",
|
||||||
"build-tauri": "npm run build-libs && NODE_ENV=production ng build --configuration production",
|
"build-tauri": "npm run build-libs && NODE_ENV=production ng build --configuration production tauri-builtin",
|
||||||
"serve-tauri-builtin": "ng serve tauri-builtin --port 4100",
|
"serve-tauri-builtin": "ng serve tauri-builtin --port 4100",
|
||||||
"serve-app": "ng serve --port 4200 --proxy-config ./proxy.json",
|
"serve-app": "ng serve --port 4200 --proxy-config ./proxy.json",
|
||||||
"tauri-dev": "npm install && run-s build-libs:dev && run-p serve-app serve-tauri-builtin"
|
"tauri-dev": "npm install && run-s build-libs:dev && run-p serve-app serve-tauri-builtin"
|
||||||
|
|
1
desktop/tauri/assets
Symbolic link
1
desktop/tauri/assets
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../assets/data
|
3
desktop/tauri/src-tauri/.gitignore
vendored
Normal file
3
desktop/tauri/src-tauri/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
7286
desktop/tauri/src-tauri/Cargo.lock
generated
Normal file
7286
desktop/tauri/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
75
desktop/tauri/src-tauri/Cargo.toml
Normal file
75
desktop/tauri/src-tauri/Cargo.toml
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Portmaster UI"
|
||||||
|
authors = ["Safing"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
default-run = "app"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.60"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0-alpha", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Tauri
|
||||||
|
tauri = { version = "2.0.0-alpha", features = ["tray-icon", "icon-ico", "icon-png"] }
|
||||||
|
tauri-plugin-shell = "2.0.0-alpha"
|
||||||
|
tauri-plugin-dialog = "2.0.0-alpha"
|
||||||
|
tauri-plugin-clipboard-manager = "2.0.0-alpha"
|
||||||
|
tauri-plugin-os = "2.0.0-alpha"
|
||||||
|
tauri-plugin-single-instance = "2.0.0-alpha"
|
||||||
|
tauri-plugin-cli = "2.0.0-alpha"
|
||||||
|
tauri-plugin-notification = "2.0.0-alpha"
|
||||||
|
|
||||||
|
# We still need the tauri-cli 1.5 for building
|
||||||
|
tauri-cli = "1.5.11"
|
||||||
|
|
||||||
|
# General
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
futures-util = { version = "0.3", features = ["sink"] }
|
||||||
|
dirs = "1.0"
|
||||||
|
rust-ini = "0.20.0"
|
||||||
|
dataurl = "0.1.2"
|
||||||
|
uuid = "1.6.1"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
tokio = { version = "1.35.0", features = ["macros"] }
|
||||||
|
cached = "0.46.1"
|
||||||
|
notify-rust = "4.10.0"
|
||||||
|
assert_matches = "1.5.0"
|
||||||
|
tokio-websockets = { version = "0.5.0", features = ["client", "ring", "rand"] }
|
||||||
|
sha = "1.0.3"
|
||||||
|
http = "1.0.0"
|
||||||
|
url = "2.5.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
log = "0.4.21"
|
||||||
|
pretty_env_logger = "0.5.0"
|
||||||
|
|
||||||
|
# Linux only
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
glib = "0.18.4"
|
||||||
|
gtk-sys = "0.18.0"
|
||||||
|
glib-sys = "0.18.1"
|
||||||
|
gdk-pixbuf = "0.18.3"
|
||||||
|
gdk-pixbuf-sys = "0.18.0"
|
||||||
|
gio-sys = "0.18.1"
|
||||||
|
|
||||||
|
# Windows only
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-service = "0.6.0"
|
||||||
|
windows = { version = "0.54.0", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
which = "6.0.0"
|
||||||
|
gtk = "0.18"
|
||||||
|
ctor = "0.2.6"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||||
|
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
|
||||||
|
# DO NOT REMOVE!!
|
||||||
|
custom-protocol = [ "tauri/custom-protocol" ]
|
3
desktop/tauri/src-tauri/build.rs
Normal file
3
desktop/tauri/src-tauri/build.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
204
desktop/tauri/src-tauri/src/main.rs
Normal file
204
desktop/tauri/src-tauri/src/main.rs
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
|
||||||
|
use tauri_plugin_cli::CliExt;
|
||||||
|
|
||||||
|
// Library crates
|
||||||
|
mod portapi;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod xdg;
|
||||||
|
|
||||||
|
// App modules
|
||||||
|
mod portmaster;
|
||||||
|
mod traymenu;
|
||||||
|
mod window;
|
||||||
|
|
||||||
|
use log::{debug, error, info, trace, warn};
|
||||||
|
use portmaster::PortmasterExt;
|
||||||
|
use traymenu::setup_tray_menu;
|
||||||
|
use window::{close_splash_window, create_main_window};
|
||||||
|
|
||||||
|
#[macro_use]
|
||||||
|
extern crate lazy_static;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct Payload {
|
||||||
|
args: Vec<String>,
|
||||||
|
cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WsHandler {
|
||||||
|
handle: AppHandle,
|
||||||
|
background: bool,
|
||||||
|
|
||||||
|
is_first_connect: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl portmaster::Handler for WsHandler {
|
||||||
|
fn on_connect(&mut self, cli: portapi::client::PortAPI) -> () {
|
||||||
|
// we successfully connected to Portmaster. Set is_first_connect to false
|
||||||
|
// so we don't show the splash-screen when we loose connection.
|
||||||
|
self.is_first_connect = false;
|
||||||
|
|
||||||
|
if let Err(err) = close_splash_window(&self.handle) {
|
||||||
|
error!("failed to close splash window: {}", err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the main window now. It's not automatically visible by default.
|
||||||
|
// Rather, the angular application will show the window itself when it finished
|
||||||
|
// bootstrapping.
|
||||||
|
if let Err(err) = create_main_window(&self.handle) {
|
||||||
|
error!("failed to create main window: {}", err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = self.handle.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
traymenu::tray_handler(cli, handle).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_disconnect(&mut self) {
|
||||||
|
// if we're not running in background and this was the first connection attempt
|
||||||
|
// then display the splash-screen.
|
||||||
|
//
|
||||||
|
// Once we had a successful connection the splash-screen will not be shown anymore
|
||||||
|
// since there's already a main window with the angular application.
|
||||||
|
if !self.background && self.is_first_connect {
|
||||||
|
let _ = window::create_splash_window(&self.handle.clone());
|
||||||
|
|
||||||
|
self.is_first_connect = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pretty_env_logger::init();
|
||||||
|
|
||||||
|
let app = tauri::Builder::default()
|
||||||
|
// Shell plugin for open_external support
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
// Clipboard support
|
||||||
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
// Dialog (Save/Open) support
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
// OS Version and Architecture support
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
// Single instance guard
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||||
|
let _ = app.emit("single-instance", Payload { args: argv, cwd });
|
||||||
|
}))
|
||||||
|
// Custom CLI arguments
|
||||||
|
.plugin(tauri_plugin_cli::init())
|
||||||
|
// Notification support
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
// Our Portmaster Plugin that handles communication between tauri and our angular app.
|
||||||
|
.plugin(portmaster::init())
|
||||||
|
// Setup the app an any listeners
|
||||||
|
.setup(|app| {
|
||||||
|
setup_tray_menu(app)?;
|
||||||
|
|
||||||
|
// Setup the single-instance event listener that will create/focus the main window
|
||||||
|
// or the splash-screen.
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
app.listen_global("single-instance", move |_event| {
|
||||||
|
let _ = window::open_window(&handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cli flags:
|
||||||
|
//
|
||||||
|
let mut background = false;
|
||||||
|
match app.cli().matches() {
|
||||||
|
Ok(matches) => {
|
||||||
|
debug!("cli matches={:?}", matches);
|
||||||
|
|
||||||
|
if let Some(bg_flag) = matches.args.get("background") {
|
||||||
|
match bg_flag.value.as_bool() {
|
||||||
|
Some(value) => {
|
||||||
|
background = value;
|
||||||
|
app.portmaster().set_show_after_bootstrap(!background);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(nf_flag) = matches.args.get("with-notifications") {
|
||||||
|
match nf_flag.value.as_bool() {
|
||||||
|
Some(v) => {
|
||||||
|
app.portmaster().with_notification_support(v);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pf_flag) = matches.args.get("with-prompts") {
|
||||||
|
match pf_flag.value.as_bool() {
|
||||||
|
Some(v) => {
|
||||||
|
app.portmaster().with_connection_prompts(v);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to parse cli arguments: {}", err.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// prepare a custom portmaster plugin handler that will show the splash-screen
|
||||||
|
// (if not in --background) and launch the tray-icon handler.
|
||||||
|
let handler = WsHandler {
|
||||||
|
handle: app.handle().clone(),
|
||||||
|
background,
|
||||||
|
is_first_connect: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// register the custom handler
|
||||||
|
app.portmaster().register_handler(handler);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.any_thread()
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
app.run(|handle, e| match e {
|
||||||
|
RunEvent::WindowEvent { label, event, .. } => {
|
||||||
|
if label != "main" {
|
||||||
|
// We only have one window at most so any other label is unexpected
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not let the user close the window, instead send an event to the main
|
||||||
|
// window so we can show the "will not stop portmaster" dialog and let the window
|
||||||
|
// close itself using
|
||||||
|
//
|
||||||
|
// window.__TAURI__.window.getCurrent().close()
|
||||||
|
//
|
||||||
|
// Note: the above javascript does NOT trigger the CloseRequested event so
|
||||||
|
// there's no need to handle that case here.
|
||||||
|
//
|
||||||
|
match event {
|
||||||
|
WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
debug!(
|
||||||
|
"window (label={}) close request received, forwarding to user-interface.",
|
||||||
|
label
|
||||||
|
);
|
||||||
|
|
||||||
|
api.prevent_close();
|
||||||
|
if let Some(window) = handle.get_window(label.as_str()) {
|
||||||
|
let _ = window.emit("exit-requested", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunEvent::ExitRequested { api, .. } => {
|
||||||
|
api.prevent_exit();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
191
desktop/tauri/src-tauri/src/portapi/client.rs
Normal file
191
desktop/tauri/src-tauri/src/portapi/client.rs
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use http::Uri;
|
||||||
|
use log::{debug, error, warn};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use tokio::sync::mpsc::{channel, Receiver, Sender};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_websockets::{ClientBuilder, Error};
|
||||||
|
|
||||||
|
use super::message::*;
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// An internal representation of a Command that
|
||||||
|
/// contains the PortAPI message as well as a response
|
||||||
|
/// channel that will receive all responses sent from the
|
||||||
|
/// server.
|
||||||
|
///
|
||||||
|
/// Users should normally not need to use the Command struct
|
||||||
|
/// directly since `PortAPI` already abstracts the creation of
|
||||||
|
/// mpsc channels.
|
||||||
|
struct Command {
|
||||||
|
msg: Message,
|
||||||
|
response: Sender<Response>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client implementation for PortAPI.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PortAPI {
|
||||||
|
dispatch: Sender<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map type used to store message subscribers.
|
||||||
|
type SubscriberMap = RwLock<HashMap<usize, Sender<Response>>>;
|
||||||
|
|
||||||
|
/// Connect to PortAPI at the specified URI.
|
||||||
|
///
|
||||||
|
/// This method will launch a new async thread on the `tauri::async_runtime`
|
||||||
|
/// that will handle message to transmit and also multiplex server responses
|
||||||
|
/// to the appropriate subscriber.
|
||||||
|
pub async fn connect(uri: &str) -> Result<PortAPI, Error> {
|
||||||
|
let parsed = match uri.parse::<Uri>() {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_e) => {
|
||||||
|
return Err(Error::NoUriConfigured); // TODO(ppacher): fix the return error type.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut client, _) = ClientBuilder::from_uri(parsed).connect().await?;
|
||||||
|
let (tx, mut dispatch) = channel::<Command>(64);
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let subscribers: SubscriberMap = RwLock::new(HashMap::new());
|
||||||
|
let next_id = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = client.next() => {
|
||||||
|
let msg = match msg {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => {
|
||||||
|
warn!("websocket connection lost");
|
||||||
|
|
||||||
|
dispatch.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to receive frame from websocket: {}", err);
|
||||||
|
|
||||||
|
dispatch.close();
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
Ok(msg) => {
|
||||||
|
let text = unsafe {
|
||||||
|
std::str::from_utf8_unchecked(msg.as_payload())
|
||||||
|
};
|
||||||
|
|
||||||
|
match text.parse::<Message>() {
|
||||||
|
Ok(msg) => {
|
||||||
|
let id = msg.id;
|
||||||
|
let map = subscribers
|
||||||
|
.read()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Some(sub) = map.get(&id) {
|
||||||
|
let res: Result<Response, MessageError> = msg.try_into();
|
||||||
|
match res {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Err(err) = sub.send(response).await {
|
||||||
|
// The receiver side has been closed already,
|
||||||
|
// drop the read lock and remove the subscriber
|
||||||
|
// from our hashmap
|
||||||
|
drop(map);
|
||||||
|
|
||||||
|
subscribers
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&id);
|
||||||
|
|
||||||
|
debug!("subscriber for command {} closed read side: {}", id, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
error!("invalid command: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to deserialize message: {}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
Some(mut cmd) = dispatch.recv() => {
|
||||||
|
let id = next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
cmd.msg.id = id;
|
||||||
|
let blob: String = cmd.msg.into();
|
||||||
|
|
||||||
|
debug!("Sending websocket frame: {}", blob);
|
||||||
|
|
||||||
|
match client.send(tokio_websockets::Message::text(blob)).await {
|
||||||
|
Ok(_) => {
|
||||||
|
subscribers
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(id, cmd.response);
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to dispatch command: {}", err);
|
||||||
|
|
||||||
|
// TODO(ppacher): we should send some error to cmd.response here.
|
||||||
|
// Otherwise, the sender of cmd might get stuck waiting for responses
|
||||||
|
// if they don't check for PortAPI.is_closed().
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(PortAPI { dispatch: tx })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortAPI {
|
||||||
|
/// `request` sends a PortAPI `portapi::types::Request` to the server and returns a mpsc receiver channel
|
||||||
|
/// where all server responses are forwarded.
|
||||||
|
///
|
||||||
|
/// If the caller does not intend to read any responses the returned receiver may be closed or
|
||||||
|
/// dropped. As soon as the async-thread launched in `connect` detects a closed receiver it is remove
|
||||||
|
/// from the subscription map.
|
||||||
|
///
|
||||||
|
/// The default buffer size for the channel is 64. Use `request_with_buffer_size` to specify a dedicated buffer size.
|
||||||
|
pub async fn request(
|
||||||
|
&self,
|
||||||
|
r: Request,
|
||||||
|
) -> std::result::Result<Receiver<Response>, MessageError> {
|
||||||
|
self.request_with_buffer_size(r, 64).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like `request` but supports explicitly specifying a channel buffer size.
|
||||||
|
pub async fn request_with_buffer_size(
|
||||||
|
&self,
|
||||||
|
r: Request,
|
||||||
|
buffer: usize,
|
||||||
|
) -> std::result::Result<Receiver<Response>, MessageError> {
|
||||||
|
let (tx, rx) = channel(buffer);
|
||||||
|
|
||||||
|
let msg: Message = r.try_into()?;
|
||||||
|
|
||||||
|
let _ = self.dispatch.send(Command { response: tx, msg }).await;
|
||||||
|
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether or not the websocket connection to the Portmaster Database API has been closed
|
||||||
|
/// due to errors.
|
||||||
|
///
|
||||||
|
/// Users are expected to check this field on a regular interval to detect any issues and perform
|
||||||
|
/// a clean re-connect by calling `connect` again.
|
||||||
|
pub fn is_closed(&self) -> bool {
|
||||||
|
self.dispatch.is_closed()
|
||||||
|
}
|
||||||
|
}
|
258
desktop/tauri/src-tauri/src/portapi/message.rs
Normal file
258
desktop/tauri/src-tauri/src/portapi/message.rs
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// MessageError describes any error that is encountered when parsing
|
||||||
|
/// PortAPI messages or when converting between the Request/Response types.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MessageError {
|
||||||
|
#[error("missing command id")]
|
||||||
|
MissingID,
|
||||||
|
|
||||||
|
#[error("invalid command id")]
|
||||||
|
InvalidID,
|
||||||
|
|
||||||
|
#[error("missing command")]
|
||||||
|
MissingCommand,
|
||||||
|
|
||||||
|
#[error("missing key")]
|
||||||
|
MissingKey,
|
||||||
|
|
||||||
|
#[error("missing payload")]
|
||||||
|
MissingPayload,
|
||||||
|
|
||||||
|
#[error("unknown or unsupported command: {0}")]
|
||||||
|
UnknownCommand(String),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidPayload(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Payload defines the payload type and content of a PortAPI message.
|
||||||
|
///
|
||||||
|
/// For the time being, only JSON payloads (indicated by a prefixed 'J' of the payload content)
|
||||||
|
/// is directly supported in `Payload::parse()`.
|
||||||
|
///
|
||||||
|
/// For other payload types (like CBOR, BSON, ...) it's the user responsibility to figure out
|
||||||
|
/// appropriate decoding from the `Payload::UNKNOWN` variant.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub enum Payload {
|
||||||
|
JSON(String),
|
||||||
|
UNKNOWN(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ParseError is returned from `Payload::parse()`.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error(transparent)]
|
||||||
|
JSON(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("unknown error while parsing")]
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Payload {
|
||||||
|
/// Parse the payload into T.
|
||||||
|
///
|
||||||
|
/// Only JSON parsing is supported for now. See [Payload] for more information.
|
||||||
|
pub fn parse<'a, T>(self: &'a Self) -> std::result::Result<T, ParseError>
|
||||||
|
where
|
||||||
|
T: serde::de::Deserialize<'a> {
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Payload::JSON(blob) => Ok(serde_json::from_str::<T>(blob.as_str())?),
|
||||||
|
Payload::UNKNOWN(_) => Err(ParseError::UNKNOWN),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supports creating a Payload instance from a String.
|
||||||
|
///
|
||||||
|
/// See [Payload] for more information.
|
||||||
|
impl std::convert::From<String> for Payload {
|
||||||
|
fn from(value: String) -> Payload {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
let first = chars.next();
|
||||||
|
let rest = chars.as_str().to_string();
|
||||||
|
|
||||||
|
match first {
|
||||||
|
Some(c) => match c {
|
||||||
|
'J' => Payload::JSON(rest),
|
||||||
|
_ => Payload::UNKNOWN(value),
|
||||||
|
},
|
||||||
|
None => Payload::UNKNOWN("".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display implementation for Payload that just displays the raw payload.
|
||||||
|
impl std::fmt::Display for Payload {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Payload::JSON(payload) => {
|
||||||
|
write!(f, "J{}", payload)
|
||||||
|
},
|
||||||
|
Payload::UNKNOWN(payload) => {
|
||||||
|
write!(f, "{}", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message is an internal representation of a PortAPI message.
|
||||||
|
/// Users should more likely use `portapi::types::Request` and `portapi::types::Response`
|
||||||
|
/// instead of directly using `Message`.
|
||||||
|
///
|
||||||
|
/// The struct is still public since it might be useful for debugging or to implement new
|
||||||
|
/// commands not yet supported by the `portapi::types` crate.
|
||||||
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: usize,
|
||||||
|
pub cmd: String,
|
||||||
|
pub key: Option<String>,
|
||||||
|
pub payload: Option<Payload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation to marshal a PortAPI message into it's wire-format representation
|
||||||
|
/// (which is a string).
|
||||||
|
///
|
||||||
|
/// Note that this conversion does not check for invalid messages!
|
||||||
|
impl std::convert::From<Message> for String {
|
||||||
|
fn from(value: Message) -> Self {
|
||||||
|
let mut result = "".to_owned();
|
||||||
|
|
||||||
|
result.push_str(value.id.to_string().as_str());
|
||||||
|
result.push_str("|");
|
||||||
|
result.push_str(&value.cmd);
|
||||||
|
|
||||||
|
if let Some(key) = value.key {
|
||||||
|
result.push_str("|");
|
||||||
|
result.push_str(key.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(payload) = value.payload {
|
||||||
|
result.push_str("|");
|
||||||
|
result.push_str(payload.to_string().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An implementation for `String::parse()` to convert a wire-format representation
|
||||||
|
/// of a PortAPI message to a Message instance.
|
||||||
|
///
|
||||||
|
/// Any errors returned from `String::parse()` will be of type `MessageError`
|
||||||
|
impl std::str::FromStr for Message {
|
||||||
|
type Err = MessageError;
|
||||||
|
|
||||||
|
fn from_str(line: &str) -> Result<Self, Self::Err> {
|
||||||
|
let parts = line.split("|").collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
let id = match parts.get(0) {
|
||||||
|
Some(s) => match (*s).parse::<usize>() {
|
||||||
|
Ok(id) => Ok(id),
|
||||||
|
Err(_) => Err(MessageError::InvalidID),
|
||||||
|
},
|
||||||
|
None => Err(MessageError::MissingID),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let cmd = match parts.get(1) {
|
||||||
|
Some(s) => Ok(*s),
|
||||||
|
None => Err(MessageError::MissingCommand),
|
||||||
|
}?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let key = parts.get(2)
|
||||||
|
.and_then(|key| Some(key.to_string()));
|
||||||
|
|
||||||
|
let payload : Option<Payload> = parts.get(3)
|
||||||
|
.and_then(|p| Some(p.to_string().into()));
|
||||||
|
|
||||||
|
return Ok(Message {
|
||||||
|
id,
|
||||||
|
cmd,
|
||||||
|
key,
|
||||||
|
payload: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Deserialize)]
|
||||||
|
struct Test {
|
||||||
|
a: i64,
|
||||||
|
s: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_to_string() {
|
||||||
|
let p = Payload::JSON("{}".to_string());
|
||||||
|
assert_eq!(p.to_string(), "J{}");
|
||||||
|
|
||||||
|
let p = Payload::UNKNOWN("some unknown content".to_string());
|
||||||
|
assert_eq!(p.to_string(), "some unknown content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_from_string() {
|
||||||
|
let p: Payload = "J{}".to_string().into();
|
||||||
|
assert_eq!(p, Payload::JSON("{}".to_string()));
|
||||||
|
|
||||||
|
let p: Payload = "some unknown content".to_string().into();
|
||||||
|
assert_eq!(p, Payload::UNKNOWN("some unknown content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_parse() {
|
||||||
|
let p: Payload = "J{\"a\": 100, \"s\": \"string\"}".to_string().into();
|
||||||
|
|
||||||
|
let t: Test = p.parse()
|
||||||
|
.expect("Expected payload parsing to work");
|
||||||
|
|
||||||
|
assert_eq!(t, Test{
|
||||||
|
a: 100,
|
||||||
|
s: "string".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_message() {
|
||||||
|
let m = "10|insert|some:key|J{}".parse::<Message>()
|
||||||
|
.expect("Expected message to parse");
|
||||||
|
|
||||||
|
assert_eq!(m, Message{
|
||||||
|
id: 10,
|
||||||
|
cmd: "insert".to_string(),
|
||||||
|
key: Some("some:key".to_string()),
|
||||||
|
payload: Some(Payload::JSON("{}".to_string())),
|
||||||
|
});
|
||||||
|
|
||||||
|
let m = "1|done".parse::<Message>()
|
||||||
|
.expect("Expected message to parse");
|
||||||
|
|
||||||
|
assert_eq!(m, Message{
|
||||||
|
id: 1,
|
||||||
|
cmd: "done".to_string(),
|
||||||
|
key: None,
|
||||||
|
payload: None
|
||||||
|
});
|
||||||
|
|
||||||
|
let m = "".parse::<Message>()
|
||||||
|
.expect_err("Expected parsing to fail");
|
||||||
|
if let MessageError::InvalidID = m {} else {
|
||||||
|
panic!("unexpected error value: {}", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = "1".parse::<Message>()
|
||||||
|
.expect_err("Expected parsing to fail");
|
||||||
|
|
||||||
|
if let MessageError::MissingCommand = m {} else {
|
||||||
|
panic!("unexpected error value: {}", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
desktop/tauri/src-tauri/src/portapi/mod.rs
Normal file
4
desktop/tauri/src-tauri/src/portapi/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod client;
|
||||||
|
pub mod message;
|
||||||
|
pub mod types;
|
||||||
|
pub mod models;
|
18
desktop/tauri/src-tauri/src/portapi/models/config.rs
Normal file
18
desktop/tauri/src-tauri/src/portapi/models/config.rs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
use serde::*;
|
||||||
|
use super::super::message::Payload;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||||
|
pub struct BooleanValue {
|
||||||
|
#[serde(rename = "Value")]
|
||||||
|
pub value: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryInto<Payload> for BooleanValue {
|
||||||
|
type Error = serde_json::Error;
|
||||||
|
|
||||||
|
fn try_into(self) -> Result<Payload, Self::Error> {
|
||||||
|
let str = serde_json::to_string(&self)?;
|
||||||
|
|
||||||
|
Ok(Payload::JSON(str))
|
||||||
|
}
|
||||||
|
}
|
4
desktop/tauri/src-tauri/src/portapi/models/mod.rs
Normal file
4
desktop/tauri/src-tauri/src/portapi/models/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod spn;
|
||||||
|
pub mod notification;
|
||||||
|
pub mod subsystem;
|
70
desktop/tauri/src-tauri/src/portapi/models/notification.rs
Normal file
70
desktop/tauri/src-tauri/src/portapi/models/notification.rs
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
use serde::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub struct Notification {
|
||||||
|
#[serde(rename = "EventID")]
|
||||||
|
pub event_id: String,
|
||||||
|
|
||||||
|
#[serde(rename = "GUID")]
|
||||||
|
pub guid: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub notification_type: NotificationType,
|
||||||
|
|
||||||
|
#[serde(rename = "Message")]
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Title")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "Category")]
|
||||||
|
pub category: String,
|
||||||
|
|
||||||
|
#[serde(rename = "EventData")]
|
||||||
|
pub data: serde_json::Value,
|
||||||
|
|
||||||
|
#[serde(rename = "Expires")]
|
||||||
|
pub expires: u64,
|
||||||
|
|
||||||
|
#[serde(rename = "State")]
|
||||||
|
pub state: String,
|
||||||
|
|
||||||
|
#[serde(rename = "AvailableActions")]
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
|
||||||
|
#[serde(rename = "SelectedActionID")]
|
||||||
|
pub selected_action_id: String,
|
||||||
|
|
||||||
|
#[serde(rename = "ShowOnSystem")]
|
||||||
|
pub show_on_system: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub struct Action {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Text")]
|
||||||
|
pub text: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub action_type: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Payload")]
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub struct NotificationType(i32);
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const INFO: NotificationType = NotificationType(0);
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const WARN: NotificationType = NotificationType(1);
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const PROMPT: NotificationType = NotificationType(2);
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const ERROR: NotificationType = NotificationType(3);
|
||||||
|
|
8
desktop/tauri/src-tauri/src/portapi/models/spn.rs
Normal file
8
desktop/tauri/src-tauri/src/portapi/models/spn.rs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
use serde::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||||
|
pub struct SPNStatus {
|
||||||
|
#[serde(rename = "Status")]
|
||||||
|
pub status: String,
|
||||||
|
}
|
45
desktop/tauri/src-tauri/src/portapi/models/subsystem.rs
Normal file
45
desktop/tauri/src-tauri/src/portapi/models/subsystem.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
use serde::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||||
|
pub struct ModuleStatus {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(rename = "Status")]
|
||||||
|
pub status: u8,
|
||||||
|
|
||||||
|
#[serde(rename = "FailureStatus")]
|
||||||
|
pub failure_status: u8,
|
||||||
|
|
||||||
|
#[serde(rename = "FailureID")]
|
||||||
|
pub failure_id: String,
|
||||||
|
|
||||||
|
#[serde(rename = "FailureMsg")]
|
||||||
|
pub failure_msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||||
|
pub struct Subsystem {
|
||||||
|
#[serde(rename = "ID")]
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Description")]
|
||||||
|
pub description: String,
|
||||||
|
|
||||||
|
#[serde(rename = "Modules")]
|
||||||
|
pub module_status: Vec<ModuleStatus>,
|
||||||
|
|
||||||
|
#[serde(rename = "FailureStatus")]
|
||||||
|
pub failure_status: u8,
|
||||||
|
}
|
||||||
|
pub const FAILURE_NONE: u8 = 0;
|
||||||
|
pub const FAILURE_HINT: u8 = 1;
|
||||||
|
pub const FAILURE_WARNING: u8 = 2;
|
||||||
|
pub const FAILURE_ERROR: u8 = 3;
|
199
desktop/tauri/src-tauri/src/portapi/types.rs
Normal file
199
desktop/tauri/src-tauri/src/portapi/types.rs
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
|
||||||
|
use super::message::*;
|
||||||
|
|
||||||
|
/// Request is a strongly typed request message
|
||||||
|
/// that can be converted to a `portapi::message::Message`
|
||||||
|
/// object for further use by the client (`portapi::client::PortAPI`).
|
||||||
|
#[derive(PartialEq, Debug)]
|
||||||
|
pub enum Request {
|
||||||
|
Get(String),
|
||||||
|
Query(String),
|
||||||
|
Subscribe(String),
|
||||||
|
QuerySubscribe(String),
|
||||||
|
Create(String, Payload),
|
||||||
|
Update(String, Payload),
|
||||||
|
Insert(String, Payload),
|
||||||
|
Delete(String),
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation to convert a internal `portapi::message::Message` to a valid
|
||||||
|
/// `Request` variant.
|
||||||
|
///
|
||||||
|
/// Any error returned will be of type `portapi::message::MessageError`.
|
||||||
|
impl std::convert::TryFrom<Message> for Request {
|
||||||
|
type Error = MessageError;
|
||||||
|
|
||||||
|
fn try_from(value: Message) -> Result<Self, Self::Error> {
|
||||||
|
match value.cmd.as_str() {
|
||||||
|
"get" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
Ok(Request::Get(key))
|
||||||
|
},
|
||||||
|
"query" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
Ok(Request::Query(key))
|
||||||
|
},
|
||||||
|
"sub" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
Ok(Request::Subscribe(key))
|
||||||
|
},
|
||||||
|
"qsub" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
Ok(Request::QuerySubscribe(key))
|
||||||
|
},
|
||||||
|
"create" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
Ok(Request::Create(key, payload))
|
||||||
|
},
|
||||||
|
"update" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
Ok(Request::Update(key, payload))
|
||||||
|
},
|
||||||
|
"insert" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
Ok(Request::Insert(key, payload))
|
||||||
|
},
|
||||||
|
"delete" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
Ok(Request::Delete(key))
|
||||||
|
},
|
||||||
|
"cancel" => {
|
||||||
|
Ok(Request::Cancel)
|
||||||
|
},
|
||||||
|
cmd => {
|
||||||
|
Err(MessageError::UnknownCommand(cmd.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An implementation to try to convert a `Request` variant into a valid
|
||||||
|
/// `portapi::message::Message` struct.
|
||||||
|
///
|
||||||
|
/// While this implementation does not yet return any errors, it's expected that
|
||||||
|
/// additional validation will be added in the future so users should already expect
|
||||||
|
/// to receive `portapi::message::MessageError`s.
|
||||||
|
impl std::convert::TryFrom<Request> for Message {
|
||||||
|
type Error = MessageError;
|
||||||
|
|
||||||
|
fn try_from(value: Request) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
Request::Get(key) => Ok(Message { id: 0, cmd: "get".to_string(), key: Some(key), payload: None }),
|
||||||
|
Request::Query(key) => Ok(Message { id: 0, cmd: "query".to_string(), key: Some(key), payload: None }),
|
||||||
|
Request::Subscribe(key) => Ok(Message { id: 0, cmd: "sub".to_string(), key: Some(key), payload: None }),
|
||||||
|
Request::QuerySubscribe(key) => Ok(Message { id: 0, cmd: "qsub".to_string(), key: Some(key), payload: None }),
|
||||||
|
Request::Create(key, value) => Ok(Message{ id: 0, cmd: "create".to_string(), key: Some(key), payload: Some(value)}),
|
||||||
|
Request::Update(key, value) => Ok(Message{ id: 0, cmd: "update".to_string(), key: Some(key), payload: Some(value)}),
|
||||||
|
Request::Insert(key, value) => Ok(Message{ id: 0, cmd: "insert".to_string(), key: Some(key), payload: Some(value)}),
|
||||||
|
Request::Delete(key) => Ok(Message { id: 0, cmd: "delete".to_string(), key: Some(key), payload: None }),
|
||||||
|
Request::Cancel => Ok(Message { id: 0, cmd: "cancel".to_string(), key: None, payload: None }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Response is strongly types PortAPI response message.
|
||||||
|
/// that can be converted to a `portapi::message::Message`
|
||||||
|
/// object for further use by the client (`portapi::client::PortAPI`).
|
||||||
|
#[derive(PartialEq, Debug)]
|
||||||
|
pub enum Response {
|
||||||
|
Ok(String, Payload),
|
||||||
|
Update(String, Payload),
|
||||||
|
New(String, Payload),
|
||||||
|
Delete(String),
|
||||||
|
Success,
|
||||||
|
Error(String),
|
||||||
|
Warning(String),
|
||||||
|
Done
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation to convert a internal `portapi::message::Message` to a valid
|
||||||
|
/// `Response` variant.
|
||||||
|
///
|
||||||
|
/// Any error returned will be of type `portapi::message::MessageError`.
|
||||||
|
impl std::convert::TryFrom<Message> for Response {
|
||||||
|
type Error = MessageError;
|
||||||
|
|
||||||
|
fn try_from(value: Message) -> Result<Self, MessageError> {
|
||||||
|
match value.cmd.as_str() {
|
||||||
|
"ok" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
|
||||||
|
Ok(Response::Ok(key, payload))
|
||||||
|
},
|
||||||
|
"upd" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
|
||||||
|
Ok(Response::Update(key, payload))
|
||||||
|
},
|
||||||
|
"new" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
let payload = value.payload.ok_or(MessageError::MissingPayload)?;
|
||||||
|
|
||||||
|
Ok(Response::New(key, payload))
|
||||||
|
},
|
||||||
|
"del" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
|
||||||
|
Ok(Response::Delete(key))
|
||||||
|
},
|
||||||
|
"success" => {
|
||||||
|
Ok(Response::Success)
|
||||||
|
},
|
||||||
|
"error" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
|
||||||
|
Ok(Response::Error(key))
|
||||||
|
},
|
||||||
|
"warning" => {
|
||||||
|
let key = value.key.ok_or(MessageError::MissingKey)?;
|
||||||
|
|
||||||
|
Ok(Response::Warning(key))
|
||||||
|
},
|
||||||
|
"done" => {
|
||||||
|
Ok(Response::Done)
|
||||||
|
},
|
||||||
|
cmd => Err(MessageError::UnknownCommand(cmd.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An implementation to try to convert a `Response` variant into a valid
|
||||||
|
/// `portapi::message::Message` struct.
|
||||||
|
///
|
||||||
|
/// While this implementation does not yet return any errors, it's expected that
|
||||||
|
/// additional validation will be added in the future so users should already expect
|
||||||
|
/// to receive `portapi::message::MessageError`s.
|
||||||
|
impl std::convert::TryFrom<Response> for Message {
|
||||||
|
type Error = MessageError;
|
||||||
|
|
||||||
|
fn try_from(value: Response) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
Response::Ok(key, payload) => Ok(Message{id: 0, cmd: "ok".to_string(), key: Some(key), payload: Some(payload)}),
|
||||||
|
Response::Update(key, payload) => Ok(Message{id: 0, cmd: "upd".to_string(), key: Some(key), payload: Some(payload)}),
|
||||||
|
Response::New(key, payload) => Ok(Message{id: 0, cmd: "new".to_string(), key: Some(key), payload: Some(payload)}),
|
||||||
|
Response::Delete(key ) => Ok(Message{id: 0, cmd: "del".to_string(), key: Some(key), payload: None}),
|
||||||
|
Response::Success => Ok(Message{id: 0, cmd: "success".to_string(), key: None, payload: None}),
|
||||||
|
Response::Warning(key) => Ok(Message{id: 0, cmd: "warning".to_string(), key: Some(key), payload: None}),
|
||||||
|
Response::Error(key) => Ok(Message{id: 0, cmd: "error".to_string(), key: Some(key), payload: None}),
|
||||||
|
Response::Done => Ok(Message{id: 0, cmd: "done".to_string(), key: None, payload: None}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub struct Record {
|
||||||
|
pub created: u64,
|
||||||
|
pub deleted: u64,
|
||||||
|
pub expires: u64,
|
||||||
|
pub modified: u64,
|
||||||
|
pub key: String,
|
||||||
|
}
|
182
desktop/tauri/src-tauri/src/portmaster/commands.rs
Normal file
182
desktop/tauri/src-tauri/src/portmaster/commands.rs
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
use super::PortmasterPlugin;
|
||||||
|
use crate::service::get_service_manager;
|
||||||
|
use crate::service::ServiceManager;
|
||||||
|
use log::debug;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use tauri::{Manager, Runtime, State, Window};
|
||||||
|
|
||||||
|
pub type Result = std::result::Result<String, String>;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Error {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn should_show<R: Runtime>(
|
||||||
|
_window: Window<R>,
|
||||||
|
portmaster: State<'_, PortmasterPlugin<R>>,
|
||||||
|
) -> Result {
|
||||||
|
if portmaster.get_show_after_bootstrap() {
|
||||||
|
debug!("[tauri:rpc:should_show] application should show after bootstrap");
|
||||||
|
|
||||||
|
Ok("show".to_string())
|
||||||
|
} else {
|
||||||
|
debug!("[tauri:rpc:should_show] application should hide after bootstrap");
|
||||||
|
|
||||||
|
Ok("hide".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn should_handle_prompts<R: Runtime>(
|
||||||
|
_window: Window<R>,
|
||||||
|
portmaster: State<'_, PortmasterPlugin<R>>,
|
||||||
|
) -> Result {
|
||||||
|
if portmaster.handle_prompts.load(Ordering::Relaxed) {
|
||||||
|
Ok("true".to_string())
|
||||||
|
} else {
|
||||||
|
Ok("false".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_state<R: Runtime>(
|
||||||
|
_window: Window<R>,
|
||||||
|
portmaster: State<'_, PortmasterPlugin<R>>,
|
||||||
|
key: String,
|
||||||
|
) -> Result {
|
||||||
|
let value = portmaster.get_state(key);
|
||||||
|
|
||||||
|
if let Some(value) = value {
|
||||||
|
Ok(value)
|
||||||
|
} else {
|
||||||
|
Ok("".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_state<R: Runtime>(
|
||||||
|
_window: Window<R>,
|
||||||
|
portmaster: State<'_, PortmasterPlugin<R>>,
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
) -> Result {
|
||||||
|
portmaster.set_state(key, value);
|
||||||
|
|
||||||
|
Ok("".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_app_info<R: Runtime>(
|
||||||
|
window: Window<R>,
|
||||||
|
response_id: String,
|
||||||
|
matching_path: String,
|
||||||
|
exec_path: String,
|
||||||
|
pid: i64,
|
||||||
|
cmdline: String,
|
||||||
|
) -> Result {
|
||||||
|
let mut id = response_id;
|
||||||
|
|
||||||
|
let info = crate::xdg::ProcessInfo {
|
||||||
|
cmdline,
|
||||||
|
exec_path,
|
||||||
|
pid,
|
||||||
|
matching_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
let cloned = id.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || match crate::xdg::get_app_info(info) {
|
||||||
|
Ok(info) => window.emit(&id, info),
|
||||||
|
Err(err) => window.emit(
|
||||||
|
&id,
|
||||||
|
Error {
|
||||||
|
error: err.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_app_info<R: Runtime>(
|
||||||
|
window: Window<R>,
|
||||||
|
response_id: String,
|
||||||
|
_matching_path: String,
|
||||||
|
_exec_path: String,
|
||||||
|
_pid: i64,
|
||||||
|
_cmdline: String,
|
||||||
|
) -> Result {
|
||||||
|
let mut id = response_id;
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
let cloned = id.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = window.emit(
|
||||||
|
&id,
|
||||||
|
Error {
|
||||||
|
error: "Unsupported OS".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_service_manager_status<R: Runtime>(window: Window<R>, response_id: String) -> Result {
|
||||||
|
let mut id = response_id;
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
let cloned = id.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = match get_service_manager() {
|
||||||
|
Ok(sm) => sm.status().map_err(|err| err.to_string()),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(result) => window.emit(&id, &result),
|
||||||
|
Err(err) => window.emit(&id, Error { error: err }),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_service<R: Runtime>(window: Window<R>, response_id: String) -> Result {
|
||||||
|
let mut id = response_id;
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
let cloned = id.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = match get_service_manager() {
|
||||||
|
Ok(sm) => sm.start().map_err(|err| err.to_string()),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(result) => window.emit(&id, &result),
|
||||||
|
Err(err) => window.emit(&id, Error { error: err }),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cloned)
|
||||||
|
}
|
294
desktop/tauri/src-tauri/src/portmaster/mod.rs
Normal file
294
desktop/tauri/src-tauri/src/portmaster/mod.rs
Normal file
|
@ -0,0 +1,294 @@
|
||||||
|
/// This module contains a custom tauri plugin that handles all communication
|
||||||
|
/// with the angular app loaded from the portmaster api.
|
||||||
|
///
|
||||||
|
/// Using a custom-plugin for this has the advantage that all code that has
|
||||||
|
/// access to a tauri::Window or a tauri::AppHandle can get access to the
|
||||||
|
/// portmaster plugin using the Runtime/Manager extension by just calling
|
||||||
|
/// window.portmaster() or app_handle.portmaster().
|
||||||
|
///
|
||||||
|
/// Any portmaster related features (like changing a portmaster setting) should
|
||||||
|
/// live in this module.
|
||||||
|
///
|
||||||
|
/// Code that handles windows should NOT live here but should rather be placed
|
||||||
|
/// in the crate root.
|
||||||
|
// The commands module contains tauri commands that are available to Javascript
|
||||||
|
// using the invoke() and our custom invokeAsync() command.
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
// The websocket module spawns an async function on tauri's runtime that manages
|
||||||
|
// a persistent connection to the Portmaster websocket API and updates the tauri Portmaster
|
||||||
|
// Plugin instance.
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
// The notification module manages system notifications from portmaster.
|
||||||
|
mod notifications;
|
||||||
|
|
||||||
|
use crate::portapi::{
|
||||||
|
client::PortAPI, message::Payload, models::config::BooleanValue, types::Request,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use log::debug;
|
||||||
|
use serde;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
AppHandle, Manager, Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait Handler {
|
||||||
|
fn on_connect(&mut self, cli: PortAPI) -> ();
|
||||||
|
fn on_disconnect(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PortmasterPlugin<R: Runtime> {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
app: AppHandle<R>,
|
||||||
|
|
||||||
|
// state allows the angular application to store arbitrary values in the
|
||||||
|
// tauri application memory using the get_state and set_state
|
||||||
|
// tauri::commands.
|
||||||
|
state: Mutex<HashMap<String, String>>,
|
||||||
|
|
||||||
|
// an atomic boolean that indicates if we're currently connected to
|
||||||
|
// portmaster or not.
|
||||||
|
is_reachable: AtomicBool,
|
||||||
|
|
||||||
|
// holds the portapi client if any.
|
||||||
|
api: Mutex<Option<PortAPI>>,
|
||||||
|
|
||||||
|
// a vector of handlers that should be invoked on connect and disconnect of
|
||||||
|
// the portmaster API.
|
||||||
|
handlers: Mutex<Vec<Box<dyn Handler + Send>>>,
|
||||||
|
|
||||||
|
// whether or not we should handle notifications here.
|
||||||
|
handle_notifications: AtomicBool,
|
||||||
|
|
||||||
|
// whether or not we should handle prompts.
|
||||||
|
handle_prompts: AtomicBool,
|
||||||
|
|
||||||
|
// whether or not the angular application should call window.show after it
|
||||||
|
// finished bootstrapping.
|
||||||
|
should_show_after_bootstrap: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime> PortmasterPlugin<R> {
|
||||||
|
/// Returns a state stored in the portmaster plugin.
|
||||||
|
pub fn get_state(&self, key: String) -> Option<String> {
|
||||||
|
let map = self.state.lock();
|
||||||
|
|
||||||
|
if let Ok(map) = map {
|
||||||
|
match map.get(&key) {
|
||||||
|
Some(value) => Some(value.clone()),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new state to the portmaster plugin.
|
||||||
|
pub fn set_state(&self, key: String, value: String) {
|
||||||
|
let map = self.state.lock();
|
||||||
|
|
||||||
|
if let Ok(mut map) = map {
|
||||||
|
map.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports wheter or not we're currently connected to the Portmaster API.
|
||||||
|
pub fn is_reachable(&self) -> bool {
|
||||||
|
self.is_reachable.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a new connection handler that is called on connect
|
||||||
|
/// and disconnect of the Portmaster websocket API.
|
||||||
|
pub fn register_handler(&self, mut handler: impl Handler + Send + 'static) {
|
||||||
|
// register_handler can only be invoked after the plugin setup
|
||||||
|
// completed. in this case, the websocket thread is already spawned and
|
||||||
|
// we might already be connected or know that the connection failed.
|
||||||
|
// Call the respective handler method immediately now.
|
||||||
|
if let Some(api) = self.get_api() {
|
||||||
|
handler.on_connect(api);
|
||||||
|
} else {
|
||||||
|
handler.on_disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut handlers) = self.handlers.lock() {
|
||||||
|
handlers.push(Box::new(handler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current portapi client.
|
||||||
|
pub fn get_api(&self) -> Option<PortAPI> {
|
||||||
|
if let Ok(mut api) = self.api.lock() {
|
||||||
|
match &mut *api {
|
||||||
|
Some(api) => Some(api.clone()),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feature functions (enable/disable certain features).
|
||||||
|
|
||||||
|
/// Configures whether or not our tauri app should show system
|
||||||
|
/// notifications. This excludes connection prompts. Use
|
||||||
|
/// with_connection_prompts to enable handling of connection prompts.
|
||||||
|
pub fn with_notification_support(&self, enable: bool) {
|
||||||
|
self.handle_notifications.store(enable, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// kick of the notification handler if we are connected.
|
||||||
|
if enable {
|
||||||
|
self.start_notification_handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures whether or not our angular application should show connection
|
||||||
|
/// prompts via tauri.
|
||||||
|
pub fn with_connection_prompts(&self, enable: bool) {
|
||||||
|
self.handle_prompts.store(enable, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not the angular application should call window.show after it
|
||||||
|
/// finished bootstrapping.
|
||||||
|
pub fn set_show_after_bootstrap(&self, show: bool) {
|
||||||
|
self.should_show_after_bootstrap
|
||||||
|
.store(show, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether or not the angular application should call window.show
|
||||||
|
/// after it finished bootstrapping.
|
||||||
|
pub fn get_show_after_bootstrap(&self) -> bool {
|
||||||
|
self.should_show_after_bootstrap.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the angular applicatoin to show the window by emitting an event.
|
||||||
|
/// It calls set_show_after_bootstrap(true) automatically so the application
|
||||||
|
/// also shows after bootstrapping.
|
||||||
|
pub fn show_window(&self) {
|
||||||
|
debug!("[tauri] showing main window");
|
||||||
|
|
||||||
|
// set show_after_bootstrap to true so the app will even show if it
|
||||||
|
// misses the event below because it's still bootstrapping.
|
||||||
|
self.set_show_after_bootstrap(true);
|
||||||
|
|
||||||
|
// ignore the error here, there's nothing we could do about it anyways.
|
||||||
|
let _ = self.app.emit("portmaster:show", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables the SPN.
|
||||||
|
pub fn set_spn_enabled(&self, enabled: bool) {
|
||||||
|
if let Some(api) = self.get_api() {
|
||||||
|
let body: Result<Payload, serde_json::Error> = BooleanValue {
|
||||||
|
value: Some(enabled),
|
||||||
|
}
|
||||||
|
.try_into();
|
||||||
|
|
||||||
|
if let Ok(payload) = body {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
_ = api
|
||||||
|
.request(Request::Update("config:spn/enable".to_string(), payload))
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//// Internal functions
|
||||||
|
fn start_notification_handler(&self) {
|
||||||
|
if let Some(api) = self.get_api() {
|
||||||
|
let cli = api.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
notifications::notification_handler(cli).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to call all on_connect handlers
|
||||||
|
fn on_connect(&self, api: PortAPI) {
|
||||||
|
self.is_reachable.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// store the new api client.
|
||||||
|
let mut guard = self.api.lock().unwrap();
|
||||||
|
*guard = Some(api.clone());
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// fire-off the notification handler.
|
||||||
|
if self.handle_notifications.load(Ordering::Relaxed) {
|
||||||
|
self.start_notification_handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut handlers) = self.handlers.lock() {
|
||||||
|
for handler in handlers.iter_mut() {
|
||||||
|
handler.on_connect(api.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to call all on_disconnect handlers
|
||||||
|
fn on_disconnect(&self) {
|
||||||
|
self.is_reachable.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// clear the current api client reference.
|
||||||
|
let mut guard = self.api.lock().unwrap();
|
||||||
|
*guard = None;
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
if let Ok(mut handlers) = self.handlers.lock() {
|
||||||
|
for handler in handlers.iter_mut() {
|
||||||
|
handler.on_disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait PortmasterExt<R: Runtime> {
|
||||||
|
fn portmaster(&self) -> &PortmasterPlugin<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||||
|
pub struct Config {}
|
||||||
|
|
||||||
|
impl<R: Runtime, T: Manager<R>> PortmasterExt<R> for T {
|
||||||
|
fn portmaster(&self) -> &PortmasterPlugin<R> {
|
||||||
|
self.state::<PortmasterPlugin<R>>().inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
|
||||||
|
Builder::<R, Option<Config>>::new("portmaster")
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::get_app_info,
|
||||||
|
commands::get_service_manager_status,
|
||||||
|
commands::start_service,
|
||||||
|
commands::get_state,
|
||||||
|
commands::set_state,
|
||||||
|
commands::should_show,
|
||||||
|
commands::should_handle_prompts
|
||||||
|
])
|
||||||
|
.setup(|app, _api| {
|
||||||
|
let plugin = PortmasterPlugin {
|
||||||
|
app: app.clone(),
|
||||||
|
state: Mutex::new(HashMap::new()),
|
||||||
|
is_reachable: AtomicBool::new(false),
|
||||||
|
handlers: Mutex::new(Vec::new()),
|
||||||
|
api: Mutex::new(None),
|
||||||
|
handle_notifications: AtomicBool::new(false),
|
||||||
|
handle_prompts: AtomicBool::new(false),
|
||||||
|
should_show_after_bootstrap: AtomicBool::new(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.manage(plugin);
|
||||||
|
|
||||||
|
// fire of the websocket handler
|
||||||
|
websocket::start_websocket_thread(app.clone());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
103
desktop/tauri/src-tauri/src/portmaster/notifications.rs
Normal file
103
desktop/tauri/src-tauri/src/portmaster/notifications.rs
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
use crate::portapi::client::*;
|
||||||
|
use crate::portapi::message::*;
|
||||||
|
use crate::portapi::models::notification::*;
|
||||||
|
use crate::portapi::types::*;
|
||||||
|
use log::error;
|
||||||
|
use notify_rust;
|
||||||
|
use serde_json::json;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use tauri::async_runtime;
|
||||||
|
|
||||||
|
pub async fn notification_handler(cli: PortAPI) {
|
||||||
|
let res = cli
|
||||||
|
.request(Request::QuerySubscribe("query notifications:".to_string()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(mut rx) = res {
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
let res = match msg {
|
||||||
|
Response::Ok(key, payload) => Some((key, payload)),
|
||||||
|
Response::New(key, payload) => Some((key, payload)),
|
||||||
|
Response::Update(key, payload) => Some((key, payload)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((key, payload)) = res {
|
||||||
|
match payload.parse::<Notification>() {
|
||||||
|
Ok(n) => {
|
||||||
|
// Skip if this one should not be shown using the system notifications
|
||||||
|
if !n.show_on_system {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if this action has already been acted on
|
||||||
|
if n.selected_action_id != "" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(ppacher): keep a reference of open notifications and close them
|
||||||
|
// if the user reacted inside the UI:
|
||||||
|
|
||||||
|
let mut notif = notify_rust::Notification::new();
|
||||||
|
notif.body(&n.message);
|
||||||
|
notif.timeout(notify_rust::Timeout::Never); // TODO(ppacher): use n.expires to calculate the timeout.
|
||||||
|
notif.summary(&n.title);
|
||||||
|
notif.icon("portmaster");
|
||||||
|
|
||||||
|
for action in n.actions {
|
||||||
|
notif.action(&action.id, &action.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let cli_clone = cli.clone();
|
||||||
|
async_runtime::spawn(async move {
|
||||||
|
let res = notif.show();
|
||||||
|
match res {
|
||||||
|
Ok(handle) => {
|
||||||
|
handle.wait_for_action(|action| {
|
||||||
|
match action {
|
||||||
|
"__closed" => {
|
||||||
|
// timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
value => {
|
||||||
|
let value = value.to_string().clone();
|
||||||
|
|
||||||
|
async_runtime::spawn(async move {
|
||||||
|
let _ = cli_clone
|
||||||
|
.request(Request::Update(
|
||||||
|
key,
|
||||||
|
Payload::JSON(
|
||||||
|
json!({
|
||||||
|
"SelectedActionID": value
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to display notification: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => match err {
|
||||||
|
ParseError::JSON(err) => {
|
||||||
|
error!("failed to parse notification: {}", err);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error!("unknown error when parsing notifications payload");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
desktop/tauri/src-tauri/src/portmaster/websocket.rs
Normal file
45
desktop/tauri/src-tauri/src/portmaster/websocket.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
use super::PortmasterExt;
|
||||||
|
use crate::portapi::client::connect;
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
|
/// Starts a backround thread (via tauri::async_runtime) that connects to the Portmaster
|
||||||
|
/// Websocket database API.
|
||||||
|
pub fn start_websocket_thread<R: Runtime>(app: AppHandle<R>) {
|
||||||
|
let app = app.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
loop {
|
||||||
|
debug!("Trying to connect to websocket endpoint");
|
||||||
|
|
||||||
|
let api = connect("ws://127.0.0.1:817/api/database/v1").await;
|
||||||
|
|
||||||
|
match api {
|
||||||
|
Ok(cli) => {
|
||||||
|
let portmaster = app.portmaster();
|
||||||
|
|
||||||
|
info!("Successfully connected to portmaster");
|
||||||
|
|
||||||
|
portmaster.on_connect(cli.clone());
|
||||||
|
|
||||||
|
while !cli.is_closed() {
|
||||||
|
let _ = sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
portmaster.on_disconnect();
|
||||||
|
|
||||||
|
warn!("lost connection to portmaster, retrying ....")
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to create portapi client: {}", err);
|
||||||
|
|
||||||
|
app.portmaster().on_disconnect();
|
||||||
|
|
||||||
|
// sleep and retry
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
17
desktop/tauri/src-tauri/src/service/manager.rs
Normal file
17
desktop/tauri/src-tauri/src/service/manager.rs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
use std::process::{Command, ExitStatus, Stdio};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
use super::status::StatusResult;
|
||||||
|
|
||||||
|
static SYSTEMCTL: &str = "systemctl";
|
||||||
|
// TODO(ppacher): add support for kdesudo and gksudo
|
||||||
|
|
||||||
|
enum SudoCommand {
|
||||||
|
Pkexec,
|
||||||
|
Gksu,
|
||||||
|
}
|
76
desktop/tauri/src-tauri/src/service/mod.rs
Normal file
76
desktop/tauri/src-tauri/src/service/mod.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
// pub mod manager;
|
||||||
|
pub mod status;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod systemd;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_service;
|
||||||
|
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use crate::service::systemd::SystemdServiceManager;
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use self::status::StatusResult;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ServiceManagerError {
|
||||||
|
#[error("unsupported service manager")]
|
||||||
|
UnsupportedServiceManager,
|
||||||
|
|
||||||
|
#[error("unsupported operating system")]
|
||||||
|
UnsupportedOperatingSystem,
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
FromUtf8Error(#[from] std::string::FromUtf8Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("{0} output={1}")]
|
||||||
|
Other(ExitStatus, String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
WindowsError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, ServiceManagerError>;
|
||||||
|
|
||||||
|
/// A common interface to the system manager service (might be systemd, openrc, sc.exe, ...)
|
||||||
|
pub trait ServiceManager {
|
||||||
|
fn status(&self) -> Result<StatusResult>;
|
||||||
|
fn start(&self) -> Result<StatusResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyServiceManager();
|
||||||
|
|
||||||
|
impl ServiceManager for EmptyServiceManager {
|
||||||
|
fn status(&self) -> Result<StatusResult> {
|
||||||
|
Err(ServiceManagerError::UnsupportedServiceManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&self) -> Result<StatusResult> {
|
||||||
|
Err(ServiceManagerError::UnsupportedServiceManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_service_manager() -> Result<impl ServiceManager> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if SystemdServiceManager::is_installed() {
|
||||||
|
info!("system service manager: systemd");
|
||||||
|
|
||||||
|
Ok(SystemdServiceManager {})
|
||||||
|
} else {
|
||||||
|
Err(ServiceManagerError::UnsupportedServiceManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return Ok(windows_service::SERVICE_MANGER.clone());
|
||||||
|
}
|
27
desktop/tauri/src-tauri/src/service/status.rs
Normal file
27
desktop/tauri/src-tauri/src/service/status.rs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
/// SystemResult defines the "success" codes when querying or starting
|
||||||
|
/// a system service.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub enum StatusResult {
|
||||||
|
// The requested system service is installed and currently running.
|
||||||
|
Running,
|
||||||
|
|
||||||
|
// The requested system service is installed but currently stopped.
|
||||||
|
Stopped,
|
||||||
|
|
||||||
|
// NotFound is returned when the system service (systemd unit for linux)
|
||||||
|
// has not been found and the system and likely means the Portmaster installtion
|
||||||
|
// is broken all together.
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StatusResult {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
StatusResult::Running => write!(f, "running"),
|
||||||
|
StatusResult::Stopped => write!(f, "stopped"),
|
||||||
|
StatusResult::NotFound => write!(f, "not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
246
desktop/tauri/src-tauri/src/service/systemd.rs
Normal file
246
desktop/tauri/src-tauri/src/service/systemd.rs
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
use log::{debug, error};
|
||||||
|
|
||||||
|
use super::status::StatusResult;
|
||||||
|
use super::{Result, ServiceManager, ServiceManagerError};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::{
|
||||||
|
fs, io,
|
||||||
|
process::{Command, ExitStatus, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
static SYSTEMCTL: &str = "systemctl";
|
||||||
|
// TODO(ppacher): add support for kdesudo and gksudo
|
||||||
|
|
||||||
|
enum SudoCommand {
|
||||||
|
Pkexec,
|
||||||
|
Gksu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::process::Output> for ServiceManagerError {
|
||||||
|
fn from(output: std::process::Output) -> Self {
|
||||||
|
let msg = String::from_utf8(output.stderr)
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| format!("Failed to run `systemctl`"));
|
||||||
|
|
||||||
|
ServiceManagerError::Other(output.status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System Service manager implementation for Linux based distros.
|
||||||
|
pub struct SystemdServiceManager {}
|
||||||
|
|
||||||
|
impl SystemdServiceManager {
|
||||||
|
/// Checks if systemctl is available in /sbin/ /bin, /usr/bin or /usr/sbin.
|
||||||
|
///
|
||||||
|
/// Note that we explicitly check those paths to avoid returning true in case
|
||||||
|
/// there's a systemctl binary in the cwd and PATH includes . since this may
|
||||||
|
/// pose a security risk of running an untrusted binary with root privileges.
|
||||||
|
pub fn is_installed() -> bool {
|
||||||
|
let paths = vec![
|
||||||
|
"/sbin/systemctl",
|
||||||
|
"/bin/systemctl",
|
||||||
|
"/usr/sbin/systemctl",
|
||||||
|
"/usr/bin/systemctl",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
debug!("checking for systemctl at path {}", path);
|
||||||
|
|
||||||
|
match fs::metadata(path) {
|
||||||
|
Ok(md) => {
|
||||||
|
debug!("found systemctl at path {} ", path);
|
||||||
|
|
||||||
|
if md.is_file() && md.permissions().mode() & 0o111 != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error!(
|
||||||
|
"systemctl binary found but invalid permissions: {}",
|
||||||
|
md.permissions().mode().to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"failed to check systemctl binary at {}: {}",
|
||||||
|
path,
|
||||||
|
err.to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
error!("failed to find systemctl binary");
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceManager for SystemdServiceManager {
|
||||||
|
fn status(&self) -> super::Result<StatusResult> {
|
||||||
|
let name = "portmaster.service";
|
||||||
|
let result = systemctl("is-active", name, false);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
// If `systemctl is-active` returns without an error code and stdout matches "active" (just to guard againt
|
||||||
|
// unhandled cases), the service can be considered running.
|
||||||
|
Ok(stdout) => {
|
||||||
|
let mut copy = stdout.to_owned();
|
||||||
|
trim_newline(&mut copy);
|
||||||
|
|
||||||
|
if copy != "active" {
|
||||||
|
// make sure the output is as we expected
|
||||||
|
Err(ServiceManagerError::Other(ExitStatus::default(), stdout))
|
||||||
|
} else {
|
||||||
|
Ok(StatusResult::Running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
if let ServiceManagerError::Other(_err, ref output) = e {
|
||||||
|
let mut copy = output.to_owned();
|
||||||
|
trim_newline(&mut copy);
|
||||||
|
|
||||||
|
if copy == "inactive" {
|
||||||
|
return Ok(StatusResult::Stopped);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("failed to run 'systemctl is-active': {}", e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed to check if the unit is running
|
||||||
|
match systemctl("cat", name, false) {
|
||||||
|
// "systemctl cat" seems to no have stable exit codes so we need
|
||||||
|
// to check the output if it looks like "No files found for yyyy.service"
|
||||||
|
// At least, the exit code are not documented for systemd v255 (newest at the time of writing)
|
||||||
|
Err(ServiceManagerError::Other(status, msg)) => {
|
||||||
|
if msg.contains("No files found for") {
|
||||||
|
Ok(StatusResult::NotFound)
|
||||||
|
} else {
|
||||||
|
Err(ServiceManagerError::Other(status, msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other error type means something went completely wrong while running systemctl altogether.
|
||||||
|
Err(e) => Err(e),
|
||||||
|
|
||||||
|
// Fine, systemctl cat worked so if the output is "inactive" we know the service is installed
|
||||||
|
// but stopped.
|
||||||
|
Ok(_) => {
|
||||||
|
// Unit seems to be installed so check the output of result
|
||||||
|
let mut stderr = e.to_string();
|
||||||
|
trim_newline(&mut stderr);
|
||||||
|
|
||||||
|
if stderr == "inactive" {
|
||||||
|
Ok(StatusResult::Stopped)
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&self) -> Result<StatusResult> {
|
||||||
|
let name = "portmaster.service";
|
||||||
|
|
||||||
|
// This time we need to run as root through pkexec or similar binaries like kdesudo/gksudo.
|
||||||
|
systemctl("start", name, true)?;
|
||||||
|
|
||||||
|
// Check the status again to be sure it's started now
|
||||||
|
self.status()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn systemctl(
|
||||||
|
cmd: &str,
|
||||||
|
unit: &str,
|
||||||
|
run_as_root: bool,
|
||||||
|
) -> std::result::Result<String, ServiceManagerError> {
|
||||||
|
let output = run(run_as_root, SYSTEMCTL, vec![cmd, unit])?;
|
||||||
|
|
||||||
|
// The command have been able to run (i.e. has been spawned and executed by the kernel).
|
||||||
|
// We now need to check the exit code and "stdout/stderr" output in case of an error.
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
|
} else {
|
||||||
|
Err(output.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run<'a>(root: bool, cmd: &'a str, args: Vec<&'a str>) -> std::io::Result<std::process::Output> {
|
||||||
|
// clone the args vector so we can insert the actual command in case we're running
|
||||||
|
// through pkexec or friends. This is just callled a couple of times on start-up
|
||||||
|
// so cloning the vector does not add any mentionable performance impact here and it's better
|
||||||
|
// than expecting a mutalble vector in the first place.
|
||||||
|
|
||||||
|
let mut args = args.to_vec();
|
||||||
|
|
||||||
|
let mut command = match root {
|
||||||
|
true => {
|
||||||
|
// if we run through pkexec and friends we need to append cmd as the second argument.
|
||||||
|
|
||||||
|
args.insert(0, cmd);
|
||||||
|
match get_sudo_cmd() {
|
||||||
|
Ok(cmd) => {
|
||||||
|
match cmd {
|
||||||
|
SudoCommand::Pkexec => {
|
||||||
|
// disable the internal text-based prompt agent from pkexec because it won't work anyway.
|
||||||
|
args.insert(0, "--disable-internal-agent");
|
||||||
|
Command::new("/usr/bin/pkexec")
|
||||||
|
}
|
||||||
|
SudoCommand::Gksu => {
|
||||||
|
args.insert(0, "--message=Please enter your password:");
|
||||||
|
args.insert(1, "--sudo-mode");
|
||||||
|
|
||||||
|
Command::new("/usr/bin/gksudo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false => Command::new(cmd),
|
||||||
|
};
|
||||||
|
|
||||||
|
command.env("LC_ALL", "C");
|
||||||
|
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
command.args(args).output()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_newline(s: &mut String) {
|
||||||
|
if s.ends_with('\n') {
|
||||||
|
s.pop();
|
||||||
|
if s.ends_with('\r') {
|
||||||
|
s.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sudo_cmd() -> std::result::Result<SudoCommand, std::io::Error> {
|
||||||
|
if let Ok(_) = fs::metadata("/usr/bin/pkexec") {
|
||||||
|
return Ok(SudoCommand::Pkexec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(_) = fs::metadata("/usr/bin/gksudo") {
|
||||||
|
return Ok(SudoCommand::Gksu);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"failed to detect sudo command",
|
||||||
|
))
|
||||||
|
}
|
167
desktop/tauri/src-tauri/src/service/windows_service.rs
Normal file
167
desktop/tauri/src-tauri/src/service/windows_service.rs
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use windows::{
|
||||||
|
core::{HSTRING, PCWSTR},
|
||||||
|
Win32::{Foundation::HWND, UI::WindowsAndMessaging::SHOW_WINDOW_CMD},
|
||||||
|
};
|
||||||
|
use windows_service::{
|
||||||
|
service::{Service, ServiceAccess},
|
||||||
|
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVICE_NAME: &str = "PortmasterCore";
|
||||||
|
|
||||||
|
pub struct WindowsServiceManager {
|
||||||
|
manager: Option<ServiceManager>,
|
||||||
|
service: Option<Service>,
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref SERVICE_MANGER: Arc<Mutex<WindowsServiceManager>> =
|
||||||
|
Arc::new(Mutex::new(WindowsServiceManager::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsServiceManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
manager: None,
|
||||||
|
service: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_manager(&mut self) -> super::Result<()> {
|
||||||
|
// Initialize service manager. This connects to the active service database and can query status.
|
||||||
|
let manager = match ServiceManager::local_computer(
|
||||||
|
None::<&str>,
|
||||||
|
ServiceManagerAccess::ENUMERATE_SERVICE, // Only query status is allowed form non privileged application.
|
||||||
|
) {
|
||||||
|
Ok(manager) => manager,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(windows_to_manager_err(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.manager = Some(manager);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_service(&mut self) -> super::Result<bool> {
|
||||||
|
if let None = self.manager {
|
||||||
|
self.init_manager()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(manager) = &self.manager {
|
||||||
|
let service = match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
|
||||||
|
Ok(service) => service,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(false); // Service is not installed.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Service is installed and the state can be queried.
|
||||||
|
self.service = Some(service);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(super::ServiceManagerError::WindowsError(
|
||||||
|
"failed to initialize manager".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::ServiceManager for Arc<Mutex<WindowsServiceManager>> {
|
||||||
|
fn status(&self) -> super::Result<super::status::StatusResult> {
|
||||||
|
if let Ok(mut manager) = self.lock() {
|
||||||
|
if let None = manager.service {
|
||||||
|
// Try to open service
|
||||||
|
if !manager.open_service()? {
|
||||||
|
// Service is not installed.
|
||||||
|
return Ok(super::status::StatusResult::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(service) = &manager.service {
|
||||||
|
match service.query_status() {
|
||||||
|
Ok(status) => match status.current_state {
|
||||||
|
windows_service::service::ServiceState::Stopped
|
||||||
|
| windows_service::service::ServiceState::StopPending
|
||||||
|
| windows_service::service::ServiceState::PausePending
|
||||||
|
| windows_service::service::ServiceState::StartPending
|
||||||
|
| windows_service::service::ServiceState::ContinuePending
|
||||||
|
| windows_service::service::ServiceState::Paused => {
|
||||||
|
// Stopped or in a transition state.
|
||||||
|
return Ok(super::status::StatusResult::Stopped);
|
||||||
|
}
|
||||||
|
windows_service::service::ServiceState::Running => {
|
||||||
|
// Everything expect Running state is considered stopped.
|
||||||
|
return Ok(super::status::StatusResult::Running);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
return Err(super::ServiceManagerError::WindowsError(err.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This should be unreachable.
|
||||||
|
Ok(super::status::StatusResult::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&self) -> super::Result<super::status::StatusResult> {
|
||||||
|
if let Ok(mut service_manager) = self.lock() {
|
||||||
|
// Check if service is installed.
|
||||||
|
if let None = &service_manager.service {
|
||||||
|
if let Err(_) = service_manager.open_service() {
|
||||||
|
return Ok(super::status::StatusResult::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run service manager with elevated privileges. This will show access popup.
|
||||||
|
unsafe {
|
||||||
|
windows::Win32::UI::Shell::ShellExecuteW(
|
||||||
|
HWND::default(),
|
||||||
|
&HSTRING::from("runas"),
|
||||||
|
&HSTRING::from("C:\\Windows\\System32\\sc.exe"),
|
||||||
|
&HSTRING::from(format!("start {}", SERVICE_NAME)),
|
||||||
|
PCWSTR::null(),
|
||||||
|
SHOW_WINDOW_CMD(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for service to start. Timeout 10s (100 * 100ms).
|
||||||
|
if let Some(service) = &service_manager.service {
|
||||||
|
for _ in 0..100 {
|
||||||
|
match service.query_status() {
|
||||||
|
Ok(status) => {
|
||||||
|
if let windows_service::service::ServiceState::Running =
|
||||||
|
status.current_state
|
||||||
|
{
|
||||||
|
return Ok(super::status::StatusResult::Running);
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(windows_to_manager_err(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Timeout starting the service.
|
||||||
|
return Ok(super::status::StatusResult::Stopped);
|
||||||
|
}
|
||||||
|
return Err(super::ServiceManagerError::WindowsError(
|
||||||
|
"failed to start service".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn windows_to_manager_err(err: windows_service::Error) -> super::ServiceManagerError {
|
||||||
|
if let windows_service::Error::Winapi(_) = err {
|
||||||
|
// Winapi does not contain the full error. Get the actual error from windows.
|
||||||
|
return super::ServiceManagerError::WindowsError(
|
||||||
|
windows::core::Error::from_win32().to_string(), // Internally will call `GetLastError()` and parse the result.
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return super::ServiceManagerError::WindowsError(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
344
desktop/tauri/src-tauri/src/traymenu.rs
Normal file
344
desktop/tauri/src-tauri/src/traymenu.rs
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use log::{debug, error};
|
||||||
|
use tauri::{
|
||||||
|
menu::{
|
||||||
|
CheckMenuItem, CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem,
|
||||||
|
SubmenuBuilder,
|
||||||
|
},
|
||||||
|
tray::{ClickType, TrayIcon, TrayIconBuilder},
|
||||||
|
Icon, Manager, Wry,
|
||||||
|
};
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
portapi::{
|
||||||
|
client::PortAPI,
|
||||||
|
message::ParseError,
|
||||||
|
models::{
|
||||||
|
config::BooleanValue,
|
||||||
|
spn::SPNStatus,
|
||||||
|
subsystem::{self, Subsystem},
|
||||||
|
},
|
||||||
|
types::{Request, Response},
|
||||||
|
},
|
||||||
|
portmaster::PortmasterExt,
|
||||||
|
window::{create_main_window, may_navigate_to_ui, open_window},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type AppIcon = TrayIcon<Wry>;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
// Set once setup_tray_menu executed.
|
||||||
|
static ref SPN_BUTTON: Mutex<Option<CheckMenuItem<Wry>>> = Mutex::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
//
|
||||||
|
const BLUE_ICON: &'static [u8] =
|
||||||
|
include_bytes!("../../assets/icons/pm_light_blue_512.ico");
|
||||||
|
const RED_ICON: &'static [u8] =
|
||||||
|
include_bytes!("../../assets/icons/pm_light_red_512.ico");
|
||||||
|
const YELLOW_ICON: &'static [u8] =
|
||||||
|
include_bytes!("../../assets/icons/pm_light_yellow_512.ico");
|
||||||
|
const GREEN_ICON: &'static [u8] =
|
||||||
|
include_bytes!("../../assets/icons/pm_light_green_512.ico");
|
||||||
|
|
||||||
|
pub fn setup_tray_menu(
|
||||||
|
app: &mut tauri::App,
|
||||||
|
) -> core::result::Result<AppIcon, Box<dyn std::error::Error>> {
|
||||||
|
// Tray menu
|
||||||
|
let close_btn = MenuItemBuilder::with_id("close", "Exit").build(app);
|
||||||
|
let open_btn = MenuItemBuilder::with_id("open", "Open").build(app);
|
||||||
|
|
||||||
|
let spn = CheckMenuItemBuilder::with_id("spn", "Use SPN").build(app);
|
||||||
|
|
||||||
|
// Store the SPN button reference
|
||||||
|
let mut button_ref = SPN_BUTTON.lock().unwrap();
|
||||||
|
*button_ref = Some(spn.clone());
|
||||||
|
|
||||||
|
let force_show_window = MenuItemBuilder::with_id("force-show", "Force Show UI").build(app);
|
||||||
|
let reload_btn = MenuItemBuilder::with_id("reload", "Reload User Interface").build(app);
|
||||||
|
let developer_menu = SubmenuBuilder::new(app, "Developer")
|
||||||
|
.items(&[&reload_btn, &force_show_window])
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Drop the reference now so we unlock immediately.
|
||||||
|
drop(button_ref);
|
||||||
|
|
||||||
|
let menu = MenuBuilder::new(app)
|
||||||
|
.items(&[
|
||||||
|
&spn,
|
||||||
|
&PredefinedMenuItem::separator(app),
|
||||||
|
&open_btn,
|
||||||
|
&close_btn,
|
||||||
|
&developer_menu,
|
||||||
|
])
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let icon = TrayIconBuilder::new()
|
||||||
|
.icon(Icon::Raw(RED_ICON.to_vec()))
|
||||||
|
.menu(&menu)
|
||||||
|
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||||
|
"close" => {
|
||||||
|
let handle = app.clone();
|
||||||
|
app.dialog()
|
||||||
|
.message("This does not stop the Portmaster system service")
|
||||||
|
.title("Do you really want to quit the user interface?")
|
||||||
|
.ok_button_label("Yes, exit")
|
||||||
|
.cancel_button_label("No")
|
||||||
|
.show(move |answer| {
|
||||||
|
if answer {
|
||||||
|
let _ = handle.emit("exit-requested", "");
|
||||||
|
handle.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"open" => {
|
||||||
|
let _ = open_window(app);
|
||||||
|
}
|
||||||
|
"reload" => {
|
||||||
|
if let Ok(mut win) = open_window(app) {
|
||||||
|
may_navigate_to_ui(&mut win, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"force-show" => {
|
||||||
|
match create_main_window(app) {
|
||||||
|
Ok(mut win) => {
|
||||||
|
may_navigate_to_ui(&mut win, true);
|
||||||
|
if let Err(err) = win.show() {
|
||||||
|
error!("[tauri] failed to show window: {}", err.to_string());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("[tauri] failed to create main window: {}", err.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"spn" => {
|
||||||
|
let btn = SPN_BUTTON.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(bt) = &*btn {
|
||||||
|
if let Ok(is_checked) = bt.is_checked() {
|
||||||
|
app.portmaster().set_spn_enabled(is_checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
error!("unknown menu event id: {}", other);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
// not supported on linux
|
||||||
|
if event.click_type == ClickType::Left {
|
||||||
|
let _ = open_window(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_icon(icon: AppIcon, subsystems: HashMap<String, Subsystem>, spn_status: String) {
|
||||||
|
// iterate over the subsytems and check if there's a module failure
|
||||||
|
let failure = subsystems
|
||||||
|
.values()
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.failure_status)
|
||||||
|
.fold(
|
||||||
|
subsystem::FAILURE_NONE,
|
||||||
|
|acc, s| {
|
||||||
|
if s > acc {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let next_icon = match failure {
|
||||||
|
subsystem::FAILURE_WARNING => YELLOW_ICON,
|
||||||
|
subsystem::FAILURE_ERROR => RED_ICON,
|
||||||
|
_ => match spn_status.as_str() {
|
||||||
|
"connected" | "connecting" => BLUE_ICON,
|
||||||
|
_ => GREEN_ICON,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = icon.set_icon(Some(Icon::Raw(next_icon.to_vec())));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
|
||||||
|
let icon = match app.tray() {
|
||||||
|
Some(icon) => icon,
|
||||||
|
None => {
|
||||||
|
error!("cancel try_handler: missing try icon");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut subsystem_subscription = match cli
|
||||||
|
.request(Request::QuerySubscribe(
|
||||||
|
"query runtime:subsystems/".to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"cancel try_handler: failed to subscribe to 'runtime:subsystems': {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spn_status_subscription = match cli
|
||||||
|
.request(Request::QuerySubscribe(
|
||||||
|
"query runtime:spn/status".to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"cancel try_handler: failed to subscribe to 'runtime:spn/status': {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spn_config_subscription = match cli
|
||||||
|
.request(Request::QuerySubscribe(
|
||||||
|
"query config:spn/enable".to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"cancel try_handler: failed to subscribe to 'runtime:spn/enable': {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = icon.set_icon(Some(Icon::Raw(BLUE_ICON.to_vec())));
|
||||||
|
|
||||||
|
let mut subsystems: HashMap<String, Subsystem> = HashMap::new();
|
||||||
|
let mut spn_status: String = "".to_string();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = subsystem_subscription.recv() => {
|
||||||
|
let msg = match msg {
|
||||||
|
Some(m) => m,
|
||||||
|
None => { break }
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = match msg {
|
||||||
|
Response::Ok(key, payload) => Some((key, payload)),
|
||||||
|
Response::New(key, payload) => Some((key, payload)),
|
||||||
|
Response::Update(key, payload) => Some((key, payload)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, payload)) = res {
|
||||||
|
match payload.parse::<Subsystem>() {
|
||||||
|
Ok(n) => {
|
||||||
|
subsystems.insert(n.id.clone(), n);
|
||||||
|
|
||||||
|
update_icon(icon.clone(), subsystems.clone(), spn_status.clone());
|
||||||
|
},
|
||||||
|
Err(err) => match err {
|
||||||
|
ParseError::JSON(err) => {
|
||||||
|
error!("failed to parse subsystem: {}", err);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error!("unknown error when parsing notifications payload");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
msg = spn_status_subscription.recv() => {
|
||||||
|
let msg = match msg {
|
||||||
|
Some(m) => m,
|
||||||
|
None => { break }
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = match msg {
|
||||||
|
Response::Ok(key, payload) => Some((key, payload)),
|
||||||
|
Response::New(key, payload) => Some((key, payload)),
|
||||||
|
Response::Update(key, payload) => Some((key, payload)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, payload)) = res {
|
||||||
|
match payload.parse::<SPNStatus>() {
|
||||||
|
Ok(value) => {
|
||||||
|
debug!("SPN status update: {}", value.status);
|
||||||
|
spn_status = value.status.clone();
|
||||||
|
|
||||||
|
update_icon(icon.clone(), subsystems.clone(), spn_status.clone());
|
||||||
|
},
|
||||||
|
Err(err) => match err {
|
||||||
|
ParseError::JSON(err) => {
|
||||||
|
error!("failed to parse spn status value: {}", err)
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
error!("unknown error when parsing spn status value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
msg = spn_config_subscription.recv() => {
|
||||||
|
let msg = match msg {
|
||||||
|
Some(m) => m,
|
||||||
|
None => { break }
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = match msg {
|
||||||
|
Response::Ok(key, payload) => Some((key, payload)),
|
||||||
|
Response::New(key, payload) => Some((key, payload)),
|
||||||
|
Response::Update(key, payload) => Some((key, payload)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, payload)) = res {
|
||||||
|
match payload.parse::<BooleanValue>() {
|
||||||
|
Ok(value) => {
|
||||||
|
let mut btn = SPN_BUTTON.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(btn) = &mut *btn {
|
||||||
|
if let Some(value) = value.value {
|
||||||
|
_ = btn.set_checked(value);
|
||||||
|
} else {
|
||||||
|
_ = btn.set_checked(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => match err {
|
||||||
|
ParseError::JSON(err) => {
|
||||||
|
error!("failed to parse config value: {}", err)
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
error!("unknown error when parsing config value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(btn) = &mut *(SPN_BUTTON.lock().unwrap()) {
|
||||||
|
_ = btn.set_checked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = icon.set_icon(Some(Icon::Raw(RED_ICON.to_vec())));
|
||||||
|
}
|
151
desktop/tauri/src-tauri/src/window.rs
Normal file
151
desktop/tauri/src-tauri/src/window.rs
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
use log::{debug, error};
|
||||||
|
use tauri::{AppHandle, Manager, Result, UserAttentionType, Window, WindowBuilder, WindowUrl};
|
||||||
|
|
||||||
|
use crate::portmaster::PortmasterExt;
|
||||||
|
|
||||||
|
/// Either returns the existing "main" window or creates a new one.
|
||||||
|
///
|
||||||
|
/// The window is not automatically shown (i.e it starts hidden).
|
||||||
|
/// If a new main window is created (i.e. the tauri app was minimized to system-tray)
|
||||||
|
/// then the window will be automatically navigated to the Portmaster UI endpoint
|
||||||
|
/// if ::websocket::is_portapi_reachable returns true.
|
||||||
|
///
|
||||||
|
/// Either the existing or the newly created window is returned.
|
||||||
|
pub fn create_main_window(app: &AppHandle) -> Result<Window> {
|
||||||
|
let mut window = if let Some(window) = app.get_window("main") {
|
||||||
|
debug!("[tauri] main window already created");
|
||||||
|
|
||||||
|
window
|
||||||
|
} else {
|
||||||
|
debug!("[tauri] creating main window");
|
||||||
|
|
||||||
|
let res = WindowBuilder::new(app, "main", WindowUrl::App("index.html".into()))
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(win) => {
|
||||||
|
win.once("tauri://error", |event| {
|
||||||
|
error!("failed to open tauri window: {}", event.payload());
|
||||||
|
});
|
||||||
|
|
||||||
|
win
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("[tauri] failed to create main window: {}", err.to_string());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the window is not yet navigated to the Portmaster UI, do it now.
|
||||||
|
may_navigate_to_ui(&mut window, false);
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Ok(_) = std::env::var("TAURI_SHOW_IMMEDIATELY") {
|
||||||
|
debug!("[tauri] TAURI_SHOW_IMMEDIATELY is set, opening window");
|
||||||
|
|
||||||
|
if let Err(err) = window.show() {
|
||||||
|
error!("[tauri] failed to show window: {}", err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_splash_window(app: &AppHandle) -> Result<Window> {
|
||||||
|
if let Some(window) = app.get_window("splash") {
|
||||||
|
let _ = window.show();
|
||||||
|
Ok(window)
|
||||||
|
} else {
|
||||||
|
let window = WindowBuilder::new(app, "splash", WindowUrl::App("index.html".into()))
|
||||||
|
.center()
|
||||||
|
.closable(false)
|
||||||
|
.focused(true)
|
||||||
|
.resizable(false)
|
||||||
|
.visible(true)
|
||||||
|
.title("Portmaster")
|
||||||
|
.inner_size(600.0, 250.0)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let _ = window.request_user_attention(Some(UserAttentionType::Informational));
|
||||||
|
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_splash_window(app: &AppHandle) -> Result<()> {
|
||||||
|
if let Some(window) = app.get_window("splash") {
|
||||||
|
return window.close();
|
||||||
|
}
|
||||||
|
return Err(tauri::Error::WindowNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a window for the tauri application.
|
||||||
|
///
|
||||||
|
/// If the main window has already been created, it is instructed to
|
||||||
|
/// show even if we're currently not connected to Portmaster.
|
||||||
|
/// This is safe since the main-window will only be created if Portmaster API
|
||||||
|
/// was reachable so the angular application must have finished bootstrapping.
|
||||||
|
///
|
||||||
|
/// If there's not main window and the Portmaster API is reachable we create a new
|
||||||
|
/// main window.
|
||||||
|
///
|
||||||
|
/// If the Portmaster API is unreachable and there's no main window yet, we show the
|
||||||
|
/// splash-screen window.
|
||||||
|
pub fn open_window(app: &AppHandle) -> Result<Window> {
|
||||||
|
if app.portmaster().is_reachable() {
|
||||||
|
match app.get_window("main") {
|
||||||
|
Some(win) => {
|
||||||
|
app.portmaster().show_window();
|
||||||
|
|
||||||
|
Ok(win)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
app.portmaster().show_window();
|
||||||
|
|
||||||
|
create_main_window(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Show splash screen");
|
||||||
|
create_splash_window(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the Portmaster Websocket database API is reachable the window will be navigated
|
||||||
|
/// to the HTTP endpoint of Portmaster to load the UI from there.
|
||||||
|
///
|
||||||
|
/// Note that only happens if the window URL does not already point to the PM API.
|
||||||
|
///
|
||||||
|
/// In #[cfg(debug_assertions)] the TAURI_PM_URL environment variable will be used
|
||||||
|
/// if set.
|
||||||
|
/// Otherwise or in release builds, it will be navigated to http://127.0.0.1:817.
|
||||||
|
pub fn may_navigate_to_ui(win: &mut Window, force: bool) {
|
||||||
|
if !win.app_handle().portmaster().is_reachable() && !force {
|
||||||
|
error!("[tauri] portmaster API is not reachable, not navigating");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if force || cfg!(debug_assertions) || win.url().host_str() != Some("localhost") {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Ok(target_url) = std::env::var("TAURI_PM_URL") {
|
||||||
|
debug!("[tauri] navigating to {}", target_url);
|
||||||
|
|
||||||
|
win.navigate(target_url.parse().unwrap());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
debug!("[tauri] navigating to http://localhost:4200");
|
||||||
|
win.navigate("http://localhost:4200".parse().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
win.navigate("http://localhost:817".parse().unwrap());
|
||||||
|
}
|
||||||
|
}
|
585
desktop/tauri/src-tauri/src/xdg/mod.rs
Normal file
585
desktop/tauri/src-tauri/src/xdg/mod.rs
Normal file
|
@ -0,0 +1,585 @@
|
||||||
|
use cached::proc_macro::once;
|
||||||
|
use dataurl::DataUrl;
|
||||||
|
use gdk_pixbuf::{Pixbuf, PixbufError};
|
||||||
|
use gtk_sys::{
|
||||||
|
gtk_icon_info_free, gtk_icon_info_get_filename, gtk_icon_theme_get_default,
|
||||||
|
gtk_icon_theme_lookup_icon, GtkIconTheme,
|
||||||
|
};
|
||||||
|
use log::{debug, error};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::c_int;
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
io::{Error, ErrorKind},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use dirs;
|
||||||
|
use ini::{Ini, ParseOption};
|
||||||
|
|
||||||
|
static mut GTK_DEFAULT_THEME: Option<*mut GtkIconTheme> = None;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref APP_INFO_CACHE: Arc<RwLock<HashMap<String, Option<AppInfo>>>> =
|
||||||
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LookupError {
|
||||||
|
#[error(transparent)]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, LookupError>;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
pub struct AppInfo {
|
||||||
|
pub icon_name: String,
|
||||||
|
pub app_name: String,
|
||||||
|
pub icon_dataurl: String,
|
||||||
|
pub comment: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
AppInfo {
|
||||||
|
icon_dataurl: "".to_string(),
|
||||||
|
icon_name: "".to_string(),
|
||||||
|
app_name: "".to_string(),
|
||||||
|
comment: "".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize, Debug)]
|
||||||
|
pub struct ProcessInfo {
|
||||||
|
pub exec_path: String,
|
||||||
|
pub cmdline: String,
|
||||||
|
pub pid: i64,
|
||||||
|
pub matching_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProcessInfo {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} (cmdline={}) (pid={}) (matching_path={})",
|
||||||
|
self.exec_path, self.cmdline, self.pid, self.matching_path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_app_info(process_info: ProcessInfo) -> Result<AppInfo> {
|
||||||
|
{
|
||||||
|
let cache = APP_INFO_CACHE.read().unwrap();
|
||||||
|
|
||||||
|
if let Some(value) = cache.get(process_info.exec_path.as_str()) {
|
||||||
|
match value {
|
||||||
|
Some(app_info) => return Ok(app_info.clone()),
|
||||||
|
None => {
|
||||||
|
return Err(LookupError::IoError(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut needles = Vec::new();
|
||||||
|
if !process_info.exec_path.is_empty() {
|
||||||
|
needles.push(process_info.exec_path.as_str())
|
||||||
|
}
|
||||||
|
if !process_info.cmdline.is_empty() {
|
||||||
|
needles.push(process_info.cmdline.as_str())
|
||||||
|
}
|
||||||
|
if !process_info.matching_path.is_empty() {
|
||||||
|
needles.push(process_info.matching_path.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort and deduplicate
|
||||||
|
needles.sort();
|
||||||
|
needles.dedup();
|
||||||
|
|
||||||
|
debug!("Searching app info for {:?}", process_info);
|
||||||
|
|
||||||
|
let mut desktop_files = Vec::new();
|
||||||
|
for dir in get_application_directories()? {
|
||||||
|
let mut files = find_desktop_files(dir.as_path())?;
|
||||||
|
desktop_files.append(&mut files);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for needle in needles.clone() {
|
||||||
|
debug!("Trying needle {} on exec path", needle);
|
||||||
|
|
||||||
|
match try_get_app_info(needle, CheckType::Exec, &desktop_files) {
|
||||||
|
Ok(mut result) => {
|
||||||
|
matches.append(&mut result);
|
||||||
|
}
|
||||||
|
Err(LookupError::IoError(ioerr)) => {
|
||||||
|
if ioerr.kind() != ErrorKind::NotFound {
|
||||||
|
return Err(ioerr.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match try_get_app_info(needle, CheckType::Name, &desktop_files) {
|
||||||
|
Ok(mut result) => {
|
||||||
|
matches.append(&mut result);
|
||||||
|
}
|
||||||
|
Err(LookupError::IoError(ioerr)) => {
|
||||||
|
if ioerr.kind() != ErrorKind::NotFound {
|
||||||
|
return Err(ioerr.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches.is_empty() {
|
||||||
|
APP_INFO_CACHE
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(process_info.exec_path, None);
|
||||||
|
|
||||||
|
Err(Error::new(ErrorKind::NotFound, format!("failed to find app info")).into())
|
||||||
|
} else {
|
||||||
|
// sort matches by length
|
||||||
|
matches.sort_by(|a, b| a.1.cmp(&b.1));
|
||||||
|
|
||||||
|
for mut info in matches {
|
||||||
|
match get_icon_as_png_dataurl(&info.0.icon_name, 32) {
|
||||||
|
Ok(du) => {
|
||||||
|
debug!(
|
||||||
|
"[xdg] best match for {:?} is {:?} with len {}",
|
||||||
|
process_info, info.0.icon_name, info.1
|
||||||
|
);
|
||||||
|
|
||||||
|
info.0.icon_dataurl = du.1;
|
||||||
|
|
||||||
|
APP_INFO_CACHE
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(process_info.exec_path, Some(info.0.clone()));
|
||||||
|
|
||||||
|
return Ok(info.0);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"{}: failed to get icon: {}",
|
||||||
|
info.0.icon_name,
|
||||||
|
err.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::new(ErrorKind::NotFound, format!("failed to find app info")).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a vector of application directories that are expected
|
||||||
|
/// to contain all .desktop files the current user has access to.
|
||||||
|
/// The result of this function is cached for 5 minutes as it's not expected
|
||||||
|
/// that application directories actually change.
|
||||||
|
#[once(time = 300, sync_writes = true, result = true)]
|
||||||
|
fn get_application_directories() -> Result<Vec<PathBuf>> {
|
||||||
|
let xdg_home = match env::var_os("XDG_DATA_HOME") {
|
||||||
|
Some(path) => PathBuf::from(path),
|
||||||
|
None => {
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.ok_or(Error::new(ErrorKind::Other, "Failed to get home directory"))?;
|
||||||
|
|
||||||
|
home.join(".local/share")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let extra_application_dirs = match env::var_os("XDG_DATA_DIRS") {
|
||||||
|
Some(paths) => env::split_paths(&paths).map(PathBuf::from).collect(),
|
||||||
|
None => {
|
||||||
|
// Fallback if XDG_DATA_DIRS is not set. If it's set, it normally already contains /usr/share and
|
||||||
|
// /usr/local/share
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/usr/share"),
|
||||||
|
PathBuf::from("/usr/local/share"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app_dirs = Vec::new();
|
||||||
|
for extra_dir in extra_application_dirs {
|
||||||
|
app_dirs.push(extra_dir.join("applications"));
|
||||||
|
}
|
||||||
|
|
||||||
|
app_dirs.push(xdg_home.join("applications"));
|
||||||
|
|
||||||
|
Ok(app_dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(ppacher): cache the result of find_desktop_files as well.
|
||||||
|
// Though, seems like we cannot use the #[cached::proc_macro::cached] or #[cached::proc_macro::once] macros here
|
||||||
|
// because [`Result<Vec<fs::DirEntry>>>`] does not implement [`Clone`]
|
||||||
|
fn find_desktop_files(path: &Path) -> Result<Vec<fs::DirEntry>> {
|
||||||
|
match path.read_dir() {
|
||||||
|
Ok(files) => {
|
||||||
|
let desktop_files = files
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| match entry.file_type() {
|
||||||
|
Ok(ft) => ft.is_file() || ft.is_symlink(),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".desktop"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(desktop_files)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// We ignore NotFound errors here because not all application
|
||||||
|
// directories need to exist.
|
||||||
|
if err.kind() == ErrorKind::NotFound {
|
||||||
|
Ok(Vec::new())
|
||||||
|
} else {
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CheckType {
|
||||||
|
Name,
|
||||||
|
Exec,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_get_app_info(
|
||||||
|
needle: &str,
|
||||||
|
check: CheckType,
|
||||||
|
desktop_files: &Vec<fs::DirEntry>,
|
||||||
|
) -> Result<Vec<(AppInfo, usize)>> {
|
||||||
|
let path = PathBuf::from(needle);
|
||||||
|
|
||||||
|
let file_name = path.as_path().file_name().unwrap_or_default().to_str();
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for file in desktop_files {
|
||||||
|
let content = Ini::load_from_file_opt(
|
||||||
|
file.path(),
|
||||||
|
ParseOption {
|
||||||
|
enabled_escape: false,
|
||||||
|
enabled_quote: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::new(ErrorKind::Other, err.to_string()))?;
|
||||||
|
|
||||||
|
let desktop_section = match content.section(Some("Desktop Entry")) {
|
||||||
|
Some(section) => section,
|
||||||
|
None => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let matches = match check {
|
||||||
|
CheckType::Name => {
|
||||||
|
let name = match desktop_section.get("Name") {
|
||||||
|
Some(name) => name,
|
||||||
|
None => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(file_name) = file_name {
|
||||||
|
if name.to_lowercase().contains(file_name) {
|
||||||
|
file_name.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CheckType::Exec => {
|
||||||
|
let exec = match desktop_section.get("Exec") {
|
||||||
|
Some(exec) => exec,
|
||||||
|
None => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if exec.to_lowercase().contains(needle) {
|
||||||
|
needle.len()
|
||||||
|
} else if let Some(file_name) = file_name {
|
||||||
|
if exec.to_lowercase().starts_with(file_name) {
|
||||||
|
file_name.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches > 0 {
|
||||||
|
debug!(
|
||||||
|
"[xdg] found matching desktop for needle {} file at {}",
|
||||||
|
needle,
|
||||||
|
file.path().to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
let info = parse_app_info(desktop_section);
|
||||||
|
|
||||||
|
result.push((info, matches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.len() > 0 {
|
||||||
|
Ok(result)
|
||||||
|
} else {
|
||||||
|
Err(Error::new(ErrorKind::NotFound, "no matching .desktop files found").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_app_info(props: &ini::Properties) -> AppInfo {
|
||||||
|
AppInfo {
|
||||||
|
icon_dataurl: "".to_string(),
|
||||||
|
app_name: props.get("Name").unwrap_or_default().to_string(),
|
||||||
|
comment: props.get("Comment").unwrap_or_default().to_string(),
|
||||||
|
icon_name: props.get("Icon").unwrap_or_default().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_icon_as_png_dataurl(name: &str, size: i8) -> Result<(String, String)> {
|
||||||
|
unsafe {
|
||||||
|
if GTK_DEFAULT_THEME.is_none() {
|
||||||
|
let theme = gtk_icon_theme_get_default();
|
||||||
|
if theme.is_null() {
|
||||||
|
debug!("You have to initialize GTK!");
|
||||||
|
return Err(Error::new(ErrorKind::Other, "You have to initialize GTK!").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme = gtk_icon_theme_get_default();
|
||||||
|
GTK_DEFAULT_THEME = Some(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut icons = Vec::new();
|
||||||
|
|
||||||
|
// push the name
|
||||||
|
icons.push(name);
|
||||||
|
|
||||||
|
// if we don't find the icon by it's name and it includes an extension,
|
||||||
|
// drop the extension and try without.
|
||||||
|
let name_without_ext;
|
||||||
|
if let Some(ext) = PathBuf::from(name).extension() {
|
||||||
|
let ext = ext.to_str().unwrap();
|
||||||
|
|
||||||
|
let mut ext_dot = String::from(".").to_owned();
|
||||||
|
ext_dot.push_str(ext);
|
||||||
|
|
||||||
|
name_without_ext = name.replace(ext_dot.as_str(), "");
|
||||||
|
icons.push(name_without_ext.as_str());
|
||||||
|
} else {
|
||||||
|
name_without_ext = String::from(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The xdg-desktop icon specification allows a fallback for icons that contains dashes.
|
||||||
|
// i.e. the following lookup order is used:
|
||||||
|
// - network-wired-secure
|
||||||
|
// - network-wired
|
||||||
|
// - network
|
||||||
|
//
|
||||||
|
name_without_ext
|
||||||
|
.split("-")
|
||||||
|
.for_each(|part| icons.push(part));
|
||||||
|
|
||||||
|
for name in icons {
|
||||||
|
debug!("trying to load icon {}", name);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let c_str = CString::new(name).unwrap();
|
||||||
|
|
||||||
|
let icon_info = gtk_icon_theme_lookup_icon(
|
||||||
|
GTK_DEFAULT_THEME.unwrap(),
|
||||||
|
c_str.as_ptr() as *const i8,
|
||||||
|
size as c_int,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if icon_info.is_null() {
|
||||||
|
error!("failed to lookup icon {}", name);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = gtk_icon_info_get_filename(icon_info);
|
||||||
|
|
||||||
|
let filename = CStr::from_ptr(filename).to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
gtk_icon_info_free(icon_info);
|
||||||
|
|
||||||
|
match read_and_convert_pixbuf(filename.clone()) {
|
||||||
|
Ok(pb) => return Ok((filename, pb)),
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to load icon from {}: {}", filename, err.to_string());
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::new(ErrorKind::NotFound, "failed to find icon").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
fn get_icon_as_file_2(ext: &str, size: i32) -> io::Result<(String, Vec<u8>)> {
|
||||||
|
let result: String;
|
||||||
|
let buf: Vec<u8>;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let filename = CString::new(ext).unwrap();
|
||||||
|
let null: u8 = 0;
|
||||||
|
let p_null = &null as *const u8;
|
||||||
|
let nullsize: usize = 0;
|
||||||
|
let mut res = 0;
|
||||||
|
let p_res = &mut res as *mut i32;
|
||||||
|
let p_res = gio_sys::g_content_type_guess(filename.as_ptr(), p_null, nullsize, p_res);
|
||||||
|
let icon = gio_sys::g_content_type_get_icon(p_res);
|
||||||
|
g_free(p_res as *mut c_void);
|
||||||
|
if DEFAULT_THEME.is_none() {
|
||||||
|
let theme = gtk_icon_theme_get_default();
|
||||||
|
if theme.is_null() {
|
||||||
|
println!("You have to initialize GTK!");
|
||||||
|
return Err(io::Error::new(io::ErrorKind::Other, "You have to initialize GTK!"))
|
||||||
|
}
|
||||||
|
let theme = gtk_icon_theme_get_default();
|
||||||
|
DEFAULT_THEME = Some(theme);
|
||||||
|
}
|
||||||
|
let icon_names = gio_sys::g_themed_icon_get_names(icon as *mut GThemedIcon) as *mut *const i8;
|
||||||
|
let icon_info = gtk_icon_theme_choose_icon(DEFAULT_THEME.unwrap(), icon_names, size, GTK_ICON_LOOKUP_NO_SVG);
|
||||||
|
let filename = gtk_icon_info_get_filename(icon_info);
|
||||||
|
|
||||||
|
gtk_icon_info_free(icon_info);
|
||||||
|
|
||||||
|
result = CStr::from_ptr(filename).to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
buf = match read_and_convert_pixbuf(result.clone()) {
|
||||||
|
Ok(pb) => pb,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
g_object_unref(icon as *mut GObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((result, buf))
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
fn read_and_convert_pixbuf(result: String) -> std::result::Result<String, glib::Error> {
|
||||||
|
let pixbuf = match Pixbuf::from_file(result.clone()) {
|
||||||
|
Ok(data) => Ok(data),
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to load icon pixbuf: {}", err.to_string());
|
||||||
|
|
||||||
|
Pixbuf::from_resource(result.clone().as_str())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match pixbuf {
|
||||||
|
Ok(data) => match data.save_to_bufferv("png", &[]) {
|
||||||
|
Ok(data) => {
|
||||||
|
let mut du = DataUrl::new();
|
||||||
|
|
||||||
|
du.set_media_type(Some("image/png".to_string()));
|
||||||
|
du.set_data(&data);
|
||||||
|
|
||||||
|
Ok(du.to_string())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(glib::Error::new(
|
||||||
|
PixbufError::Failed,
|
||||||
|
err.to_string().as_str(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ctor::ctor;
|
||||||
|
use log::warn;
|
||||||
|
use which::which;
|
||||||
|
|
||||||
|
// Use the ctor create to setup a global initializer before our tests are executed.
|
||||||
|
#[ctor]
|
||||||
|
fn init() {
|
||||||
|
// we need to initialize GTK before running our tests.
|
||||||
|
// This is only required when unit tests are executed as
|
||||||
|
// GTK will otherwise be initialize by Tauri.
|
||||||
|
|
||||||
|
gtk::init().expect("failed to initialize GTK for tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_info_success() {
|
||||||
|
// we expect at least one of the following binaries to be installed
|
||||||
|
// on a linux system
|
||||||
|
let test_binaries = vec![
|
||||||
|
"vim", // vim is mostly bundled with a .desktop file
|
||||||
|
"blueman-manager", // blueman-manager is the default bluetooth manager on most DEs
|
||||||
|
"nautilus", // nautlis: file-manager on GNOME DE
|
||||||
|
"thunar", // thunar: file-manager on XFCE
|
||||||
|
"dolphin", // dolphin: file-manager on KDE
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut bin_found = false;
|
||||||
|
|
||||||
|
for cmd in test_binaries {
|
||||||
|
match which(cmd) {
|
||||||
|
Ok(bin) => {
|
||||||
|
bin_found = true;
|
||||||
|
|
||||||
|
let bin = bin.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = get_app_info(ProcessInfo {
|
||||||
|
cmdline: cmd.to_string(),
|
||||||
|
exec_path: bin.clone(),
|
||||||
|
matching_path: bin.clone(),
|
||||||
|
pid: 0,
|
||||||
|
})
|
||||||
|
.expect(
|
||||||
|
format!(
|
||||||
|
"expected to find app info for {} ({})",
|
||||||
|
bin,
|
||||||
|
cmd.to_string()
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty_string = String::from("");
|
||||||
|
|
||||||
|
// just make sure all fields are populated
|
||||||
|
assert_ne!(result.app_name, empty_string);
|
||||||
|
assert_ne!(result.comment, empty_string);
|
||||||
|
assert_ne!(result.icon_name, empty_string);
|
||||||
|
assert_ne!(result.icon_dataurl, empty_string);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// binary not found
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bin_found {
|
||||||
|
warn!("test_find_info_success: no test binary found, test was skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
desktop/tauri/src-tauri/tauri.conf.json
Normal file
106
desktop/tauri/src-tauri/tauri.conf.json
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": {
|
||||||
|
"script": "npm run tauri-dev",
|
||||||
|
"cwd": "../../angular",
|
||||||
|
"wait": false
|
||||||
|
},
|
||||||
|
"devPath": "http://localhost:4100",
|
||||||
|
"distDir": "../../angular/dist/tauri-builtin",
|
||||||
|
"withGlobalTauri": true
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"productName": "Portmaster",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"cli": {
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"short": "d",
|
||||||
|
"name": "data",
|
||||||
|
"description": "Path to the installation directory",
|
||||||
|
"takesValue": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"short": "b",
|
||||||
|
"name": "background",
|
||||||
|
"description": "Start in the background without opening a window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "with-notifications",
|
||||||
|
"description": "Enable experimental notifications via Tauri. Replaces the notifier app."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "with-prompts",
|
||||||
|
"description": "Enable experimental prompt support via Tauri. Replaces the notifier app."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"category": "Utility",
|
||||||
|
"copyright": "",
|
||||||
|
"deb": {
|
||||||
|
"depends": []
|
||||||
|
},
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/portmaster-start",
|
||||||
|
"binaries/portmaster-core"
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
"../assets/icons/pm_dark_512.png",
|
||||||
|
"../assets/icons/pm_dark_512.ico",
|
||||||
|
"../assets/icons/pm_light_512.png",
|
||||||
|
"../assets/icons/pm_light_512.ico"
|
||||||
|
],
|
||||||
|
"identifier": "io.safing.portmaster",
|
||||||
|
"longDescription": "",
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": null,
|
||||||
|
"exceptionDomain": "",
|
||||||
|
"frameworks": [],
|
||||||
|
"providerShortName": null,
|
||||||
|
"signingIdentity": null
|
||||||
|
},
|
||||||
|
"resources": [],
|
||||||
|
"shortDescription": "",
|
||||||
|
"targets": [
|
||||||
|
"deb",
|
||||||
|
"appimage",
|
||||||
|
"nsis",
|
||||||
|
"msi",
|
||||||
|
"app"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": null,
|
||||||
|
"dangerousRemoteDomainIpcAccess": [
|
||||||
|
{
|
||||||
|
"windows": [
|
||||||
|
"main",
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"shell",
|
||||||
|
"os",
|
||||||
|
"clipboard-manager",
|
||||||
|
"event",
|
||||||
|
"window",
|
||||||
|
"cli",
|
||||||
|
"portmaster"
|
||||||
|
],
|
||||||
|
"domain": "localhost"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windows": []
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue