feat: hoppscotch agent and agent interceptor (#4396)

Co-authored-by: CuriousCorrelation <CuriousCorrelation@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Andrew Bastin
2024-10-03 20:26:30 +05:30
committed by GitHub
parent 0f27cf2d49
commit f75900ed30
106 changed files with 14636 additions and 609 deletions

View File

@@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

33
packages/hoppscotch-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View File

@@ -0,0 +1,16 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

View File

@@ -0,0 +1,161 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1727098005,
"owner": "cachix",
"repo": "devenv",
"rev": "f318d27a4637aff765a378106d82dfded124c3b3",
"treeHash": "c77efa71afb25615542aed9d7e805a3b6216b6a2",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1727159616,
"owner": "nix-community",
"repo": "fenix",
"rev": "4306d494985e00719573bbdeb863c27c6d83dc9c",
"treeHash": "53136d2b5e6c46a3abe585771e60cd0646f69cf7",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"treeHash": "2addb7b71a20a25ea74feeaf5c2f6a6b30898ecb",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"treeHash": "ca14199cabdfe1a06a7b1654c76ed49100a689f9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1727089097,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "568bfef547c14ca438c56a0bece08b8bb2b71a9c",
"treeHash": "2c4d922ed00a8d8d8b136ad40a904239e071dfc3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1726969270,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "23cbb250f3bf4f516a2d0bf03c51a30900848075",
"treeHash": "f150876866adcc3af432c103db116c2f516f49b1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1726745158,
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "4e743a6920eab45e8ba0fbe49dc459f1423a4b74",
"treeHash": "56fbe2a9610b3ad9163a74011131e7624f6b3b81",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"fenix": "fenix",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1727104575,
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "3d0343251fe084b335b55c17a52bb4a3527b1bd0",
"treeHash": "67f4408ff2f6d7099cecc1dac6389b199fd980f9",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -0,0 +1,94 @@
{ pkgs, lib, config, inputs, ... }:
{
# https://devenv.sh/packages/
packages = with pkgs; [
git
openssl
postgresql_16
jq
xxd
# BE and Tauri stuff
libsoup_3
webkitgtk_4_1
librsvg
libappindicator
libayatana-appindicator
libappindicator-gtk3
# FE and Node stuff
nodejs_22
nodePackages_latest.typescript-language-server
nodePackages_latest.vls
nodePackages_latest.prisma
prisma-engines
];
# https://devenv.sh/basics/
#
# NOTE: Setting these `PRISMA_*` environment variable fixes
# Error: Failed to fetch sha256 checksum at https://binaries.prisma.sh/all_commits/<hash>/linux-nixos/libquery_engine.so.node.gz.sha256 - 404 Not Found
# See: https://github.com/prisma/prisma/discussions/3120
env = {
APP_GREET = "Hoppscotch";
PRISMA_QUERY_ENGINE_LIBRARY = "${pkgs.prisma-engines}/lib/libquery_engine.node";
PRISMA_QUERY_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/query-engine";
PRISMA_SCHEMA_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/schema-engine";
LD_LIBRARY_PATH = lib.makeLibraryPath [
pkgs.libappindicator
pkgs.libayatana-appindicator
pkgs.libappindicator-gtk3
];
};
# https://devenv.sh/scripts/
scripts.hello.exec = "echo hello from $APP_GREET";
enterShell = ''
git --version
'';
# https://devenv.sh/tests/
enterTest = ''
echo "Running tests"
'';
# https://devenv.sh/integrations/dotenv/
dotenv.enable = true;
# https://devenv.sh/languages/
languages.javascript = {
enable = true;
pnpm = {
enable = true;
};
npm = {
enable = true;
};
};
languages.typescript.enable = true;
languages.rust = {
enable = true;
channel = "nightly";
components = [
"rustc"
"cargo"
"clippy"
"rustfmt"
"rust-analyzer"
"llvm-tools-preview"
"rust-src"
"rustc-codegen-cranelift-preview"
];
};
# https://devenv.sh/pre-commit-hooks/
# pre-commit.hooks.shellcheck.enable = true;
# https://devenv.sh/processes/
# processes.ping.exec = "ping example.com";
# See full reference at https://devenv.sh/reference/options/
}

View File

@@ -0,0 +1,23 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
# For NodeJS-22 and above
nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable
# nixpkgs:
# url: github:cachix/devenv-nixpkgs/rolling
fenix:
url: github:nix-community/fenix
inputs:
nixpkgs:
follows: nixpkgs
# If you're using non-OSS software, you can set allowUnfree to true.
allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hoppscotch Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
{
"name": "hoppscotch-agent",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@hoppscotch/ui": "^0.2.1",
"@tauri-apps/api": ">=2.0.0-rc.0",
"@tauri-apps/plugin-shell": ">=2.0.0-rc.0",
"@vueuse/core": "^11.1.0",
"axios": "^1.7.7",
"fp-ts": "^2.16.9",
"vue": "^3.3.4"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.6",
"@tauri-apps/cli": ">=2.0.0-rc.0",
"@types/node": "^22.7.0",
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.2.2",
"unplugin-icons": "^0.19.3",
"vite": "^5.4.7",
"vue-tsc": "^2.0.22"
}
}

2952
packages/hoppscotch-agent/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
[package]
name = "hoppscotch-agent"
version = "0.1.0"
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
authors = ["CuriousCorrelation", "AndrewBastin"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "hoppscotch_agent_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
[dependencies]
tauri = { version = "2.0.0-rc.0", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-autostart = "2.0.0-rc"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.40.0", features = ["full"] }
dashmap = { version = "6.1.0", features = ["serde"] }
axum = { version = "0.7.6" }
axum-extra = { version = "0.9.4", features = ["typed-header"] }
tower-http = { version = "0.6.1", features = ["cors"] }
tokio-util = "0.7.12"
uuid = { version = "1.10.0", features = [ "v4", "fast-rng" ] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8.5"
log = "0.4.22"
env_logger = "0.11.5"
curl = { version = "0.4.46", features = ["ntlm", "static-curl", "static-ssl"] }
openssl = { version = "0.10.66", features = ["vendored"] }
openssl-sys = { version = "0.9.103", features = ["vendored"] }
url-escape = "0.1.1"
thiserror = "1.0.64"
tauri-plugin-store = "2.0.0-rc.3"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
base16 = "0.2.1"
aes-gcm = { version = "0.10.3", features = ["aes"] }
tauri-plugin-updater = "2.0.0-rc.3"
tauri-plugin-dialog = "2.0.0-rc.7"
http = "1.1.0"
lazy_static = "1.5.0"
[dev-dependencies]
mockito = "1.5.0"

View File

@@ -0,0 +1,5 @@
fn main() {
tauri_build::build();
println!("cargo::rerun-if-env-changed=UPDATER_PUB_KEY");
println!("cargo::rerun-if-env-changed=UPDATER_URL");
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "test"],
"permissions": [
"core:default",
"shell:allow-open",
"core:window:allow-close",
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,219 @@
use axum::{
body::Bytes,
extract::{Path, State},
http::HeaderMap,
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{
error::{AppError, AppResult},
model::{
AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, RequestDef,
RunRequestResponse,
},
state::{AppState, Registration},
util::EncryptedJson,
};
use chrono::Utc;
use rand::Rng;
use serde_json::json;
use uuid::Uuid;
fn generate_otp() -> String {
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
format!("{:06}", otp)
}
pub async fn handshake(
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>
) -> AppResult<Json<HandshakeResponse>> {
Ok(Json(HandshakeResponse {
status: "success".to_string(),
__hoppscotch__agent__: true,
agent_version: app_handle.package_info().version.to_string()
}))
}
pub async fn receive_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AppResult<Json<serde_json::Value>> {
let otp = generate_otp();
let mut active_registration_code = state.active_registration_code.write().await;
if !active_registration_code.is_none() {
return Ok(Json(
json!({ "message": "There is already an existing registration happening" }),
));
}
*active_registration_code = Some(otp.clone());
app_handle
.emit("registration_received", otp)
.map_err(|_| AppError::InternalServerError)?;
Ok(Json(
json!({ "message": "Registration received and stored" }),
))
}
pub async fn verify_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
) -> AppResult<Json<AuthKeyResponse>> {
state
.validate_registration(&confirmed_registration.registration)
.await
.then_some(())
.ok_or(AppError::InvalidRegistration)?;
let auth_key = Uuid::new_v4().to_string();
let created_at = Utc::now();
let auth_key_copy = auth_key.clone();
let agent_secret_key = EphemeralSecret::random();
let agent_public_key = PublicKey::from(&agent_secret_key);
let their_public_key = {
let public_key_slice: &[u8; 32] = &base16::decode(&confirmed_registration.client_public_key_b16)
.map_err(|_| AppError::InvalidClientPublicKey)?
[0..32]
.try_into()
.map_err(|_| AppError::InvalidClientPublicKey)?;
PublicKey::from(public_key_slice.to_owned())
};
let shared_secret = agent_secret_key.diffie_hellman(&their_public_key);
let _ = state.update_registrations(app_handle.clone(), |regs| {
regs.insert(auth_key_copy, Registration {
registered_at: created_at,
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes())
});
})?;
let auth_payload = json!({
"auth_key": auth_key,
"created_at": created_at
});
app_handle
.emit("authenticated", &auth_payload)
.map_err(|_| AppError::InternalServerError)?;
Ok(Json(AuthKeyResponse {
auth_key,
created_at,
agent_public_key_b16: base16::encode_lower(agent_public_key.as_bytes()),
}))
}
pub async fn run_request<T>(
State((state, _app_handle)): State<(Arc<AppState>, T)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
headers: HeaderMap,
body: Bytes
) -> AppResult<EncryptedJson<RunRequestResponse>> {
let nonce = headers.get("X-Hopp-Nonce")
.ok_or(AppError::Unauthorized)?
.to_str()
.map_err(|_| AppError::Unauthorized)?;
let req: RequestDef = state.validate_access_and_get_data(auth_header.token(), nonce, &body)
.ok_or(AppError::Unauthorized)?;
let reg_info = state.get_registration_info(auth_header.token())
.ok_or(AppError::Unauthorized)?;
let cancel_token = tokio_util::sync::CancellationToken::new();
state.add_cancellation_token(req.req_id, cancel_token.clone());
let req_id = req.req_id;
let cancel_token_clone = cancel_token.clone();
// Execute the HTTP request in a blocking thread pool and handles cancellation.
//
// It:
// 1. Uses `spawn_blocking` to run the sync `run_request_task`
// without blocking the main Tokio runtime.
// 2. Uses `select!` to concurrently wait for either
// a. the task to complete,
// b. or a cancellation signal.
//
// Why spawn_blocking?
// - `run_request_task` uses synchronous curl operations which would block
// the async runtime if not run in a separate thread.
// - `spawn_blocking` moves this operation to a thread pool designed for
// blocking tasks, so other async operations to continue unblocked.
let result = tokio::select! {
res = tokio::task::spawn_blocking(move || crate::interceptor::run_request_task(&req, cancel_token_clone)) => {
match res {
Ok(task_result) => task_result,
Err(_) => Err(AppError::InternalServerError),
}
},
_ = cancel_token.cancelled() => {
Err(AppError::RequestCancelled)
}
};
state.remove_cancellation_token(req_id);
result.map(|val| {
EncryptedJson {
key_b16: reg_info.shared_secret_b16,
data: val
}
})
}
/// Provides a way for registered clients to check if their
/// registration still holds, this route is supposed to return
/// an encrypted `true` value if the given auth_key is good.
/// Since its encrypted with the shared secret established during
/// registration, the client also needs the shared secret to verify
/// if the read fails, or the auth_key didn't validate and this route returns
/// undefined, we can count on the registration not being valid anymore.
pub async fn registered_handshake(
State((state, _)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
) -> AppResult<EncryptedJson<serde_json::Value>> {
let reg_info = state.get_registration_info(auth_header.token());
match reg_info {
Some(reg) => Ok(EncryptedJson {
key_b16: reg.shared_secret_b16,
data: json!(true),
}),
None => Err(AppError::Unauthorized),
}
}
pub async fn cancel_request<T>(
State((state, _app_handle)): State<(Arc<AppState>, T)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(req_id): Path<usize>,
) -> AppResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
return Err(AppError::Unauthorized);
}
if let Some((_, token)) = state.remove_cancellation_token(req_id) {
token.cancel();
Ok(Json(json!({"message": "Request cancelled successfully"})))
} else {
Err(AppError::RequestNotFound)
}
}

View File

@@ -0,0 +1,75 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Invalid Registration")]
InvalidRegistration,
#[error("Invalid Client Public Key")]
InvalidClientPublicKey,
#[error("Unauthorized")]
Unauthorized,
#[error("Request not found or already completed")]
RequestNotFound,
#[error("Internal server error")]
InternalServerError,
#[error("Invalid request: {0}")]
BadRequest(String),
#[error("Client certificate error")]
ClientCertError,
#[error("Root certificate error")]
RootCertError,
#[error("Invalid method")]
InvalidMethod,
#[error("Invalid URL")]
InvalidUrl,
#[error("Invalid headers")]
InvalidHeaders,
#[error("Request run error: {0}")]
RequestRunError(String),
#[error("Request cancelled")]
RequestCancelled,
#[error("Failed to clear registrations")]
RegistrationClearError,
#[error("Failed to insert registrations")]
RegistrationInsertError,
#[error("Failed to save registrations to store")]
RegistrationSaveError,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::InvalidRegistration => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::InvalidMethod => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::InvalidUrl => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::InvalidHeaders => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::RequestRunError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
AppError::RequestCancelled => (StatusCode::BAD_REQUEST, self.to_string()),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error".to_string(),
),
};
let body = Json(json!({
"error": error_message,
}));
(status, body).into_response()
}
}
pub type AppResult<T> = std::result::Result<T, AppError>;

View File

@@ -0,0 +1,567 @@
use crate::{
error::AppError,
model::{BodyDef, ClientCertDef, FormDataValue, KeyValuePair, RequestDef, RunRequestResponse},
util::get_status_text,
};
use curl::easy::{Easy, List};
use openssl::{pkcs12::Pkcs12, ssl::SslContextBuilder, x509::X509};
use openssl_sys::SSL_CTX;
use std::time::SystemTime;
use tokio_util::sync::CancellationToken;
pub(crate) fn run_request_task(
req: &RequestDef,
cancel_token: CancellationToken,
) -> Result<RunRequestResponse, AppError> {
let mut curl_handle = Easy::new();
curl_handle
.progress(true)
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
curl_handle
.custom_request(&req.method)
.map_err(|_| AppError::InvalidMethod)?;
curl_handle
.url(&req.endpoint)
.map_err(|_| AppError::InvalidUrl)?;
curl_handle
.http_headers(get_headers_list(&req)?)
.map_err(|_| AppError::InvalidHeaders)?;
apply_body_to_curl_handle(&mut curl_handle, &req)?;
curl_handle
.ssl_verify_peer(req.validate_certs)
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
curl_handle
.ssl_verify_host(req.validate_certs)
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
apply_client_cert_to_curl_handle(&mut curl_handle, &req)?;
apply_proxy_config_to_curl_handle(&mut curl_handle, &req)?;
let mut response_body = Vec::new();
let mut response_headers = Vec::new();
let (start_time_ms, end_time_ms) = {
let mut transfer = curl_handle.transfer();
transfer
.ssl_ctx_function(|ssl_ctx_ptr| {
let cert_list = get_x509_certs_from_root_cert_bundle(&req);
if !cert_list.is_empty() {
let mut ssl_ctx_builder =
unsafe { SslContextBuilder::from_ptr(ssl_ctx_ptr as *mut SSL_CTX) };
let cert_store = ssl_ctx_builder.cert_store_mut();
for cert in cert_list {
if let Err(e) = cert_store.add_cert(cert) {
eprintln!("Failed writing cert into cert store: {}", e);
}
}
}
Ok(())
})
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
transfer
.progress_function(|_, _, _, _| !cancel_token.is_cancelled())
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
transfer
.header_function(|header| {
let header = String::from_utf8_lossy(header).into_owned();
if let Some((key, value)) = header.split_once(':') {
response_headers.push(KeyValuePair {
key: key.trim().to_string(),
value: value.trim().to_string(),
});
}
true
})
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
transfer
.write_function(|data| {
response_body.extend_from_slice(data);
Ok(data.len())
})
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
let start_time_ms = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
transfer
.perform()
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
let end_time_ms = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
(start_time_ms, end_time_ms)
};
let response_status = curl_handle
.response_code()
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?
as u16;
let response_status_text = get_status_text(response_status).to_string();
Ok(RunRequestResponse {
status: response_status,
status_text: response_status_text,
headers: response_headers,
data: response_body,
time_start_ms: start_time_ms,
time_end_ms: end_time_ms,
})
}
fn get_headers_list(req: &RequestDef) -> Result<List, AppError> {
let mut result = List::new();
for KeyValuePair { key, value } in &req.headers {
result
.append(&format!("{}: {}", key, value))
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
}
Ok(result)
}
fn apply_body_to_curl_handle(curl_handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
match &req.body {
Some(BodyDef::Text(text)) => {
curl_handle
.post_fields_copy(text.as_bytes())
.map_err(|err| {
AppError::RequestRunError(format!(
"Error while setting body: {}",
err.description()
))
})?;
}
Some(BodyDef::FormData(entries)) => {
let mut form = curl::easy::Form::new();
for entry in entries {
let mut part = form.part(&entry.key);
match &entry.value {
FormDataValue::Text(data) => {
part.contents(data.as_bytes());
}
FormDataValue::File {
filename,
data,
mime,
} => {
part.buffer(filename, data.clone()).content_type(mime);
}
};
part.add().map_err(|err| {
AppError::RequestRunError(format!(
"Error while setting body: {}",
err.description()
))
})?;
}
curl_handle.httppost(form).map_err(|err| {
AppError::RequestRunError(format!(
"Error while setting body: {}",
err.description()
))
})?;
}
Some(BodyDef::URLEncoded(entries)) => {
let data = entries
.iter()
.map(|KeyValuePair { key, value }| {
format!(
"{}={}",
&url_escape::encode_www_form_urlencoded(key),
url_escape::encode_www_form_urlencoded(value)
)
})
.collect::<Vec<String>>()
.join("&");
curl_handle
.post_fields_copy(data.as_bytes())
.map_err(|err| {
AppError::RequestRunError(format!(
"Error while setting body: {}",
err.description()
))
})?;
}
None => {}
};
Ok(())
}
fn apply_client_cert_to_curl_handle(handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
match &req.client_cert {
Some(ClientCertDef::PEMCert {
certificate_pem,
key_pem,
}) => {
handle.ssl_cert_type("PEM").map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM Cert Type: {}",
err.description()
))
})?;
handle.ssl_cert_blob(certificate_pem).map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM Cert Blob: {}",
err.description()
))
})?;
handle.ssl_key_type("PEM").map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM key type: {}",
err.description()
))
})?;
handle.ssl_key_blob(key_pem).map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM Cert blob: {}",
err.description()
))
})?;
}
Some(ClientCertDef::PFXCert {
certificate_pfx,
password,
}) => {
let pkcs12 = Pkcs12::from_der(&certificate_pfx).map_err(|err| {
AppError::RequestRunError(format!(
"Failed to parse PFX certificate from DER: {}",
err
))
})?;
let parsed = pkcs12.parse2(password).map_err(|err| {
AppError::RequestRunError(format!(
"Failed to parse PFX certificate with provided password: {}",
err
))
})?;
if let (Some(cert), Some(key)) = (parsed.cert, parsed.pkey) {
let certificate_pem = cert.to_pem().map_err(|err| {
AppError::RequestRunError(format!(
"Failed to convert PFX certificate to PEM format: {}",
err
))
})?;
let key_pem = key.private_key_to_pem_pkcs8().map_err(|err| {
AppError::RequestRunError(format!(
"Failed to convert PFX private key to PEM format: {}",
err
))
})?;
handle.ssl_cert_type("PEM").map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM Cert Type for converted PFX: {}",
err.description()
))
})?;
handle.ssl_cert_blob(&certificate_pem).map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM Cert Blob for converted PFX: {}",
err.description()
))
})?;
handle.ssl_key_type("PEM").map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM key type for converted PFX: {}",
err.description()
))
})?;
handle.ssl_key_blob(&key_pem).map_err(|err| {
AppError::RequestRunError(format!(
"Failed setting PEM key blob for converted PFX: {}",
err.description()
))
})?;
} else {
return Err(AppError::RequestRunError(
"PFX certificate parsing succeeded, but either cert or private key is missing"
.to_string(),
));
}
}
None => {}
};
Ok(())
}
fn get_x509_certs_from_root_cert_bundle(req: &RequestDef) -> Vec<X509> {
req.root_cert_bundle_files
.iter()
.map(|pem_bundle| openssl::x509::X509::stack_from_pem(pem_bundle))
.filter_map(|certs| {
if let Ok(certs) = certs {
Some(certs)
} else {
None
}
})
.flatten()
.collect()
}
fn apply_proxy_config_to_curl_handle(handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
if let Some(proxy_config) = &req.proxy {
handle
.proxy_auth(curl::easy::Auth::new().auto(true))
.map_err(|err| {
AppError::RequestRunError(format!(
"Failed to set proxy Auth Mode: {}",
err.description()
))
})?;
handle.proxy(&proxy_config.url).map_err(|err| {
AppError::RequestRunError(format!("Failed to set proxy URL: {}", err.description()))
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::model::FormDataEntry;
use super::*;
use mockito::Server;
#[test]
fn test_run_request_task_success() {
let mut server = Server::new();
let mock = server
.mock("GET", "/test")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body("Hello, World!")
.create();
let req = RequestDef {
req_id: 1,
method: "GET".to_string(),
endpoint: format!("{}/test", server.url()),
headers: vec![],
body: None,
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.status_text, "OK");
assert!(response
.headers
.iter()
.any(|h| h.key == "content-type" && h.value == "text/plain"));
assert_eq!(response.data, b"Hello, World!");
mock.assert();
}
#[test]
fn test_run_request_task_with_headers() {
let mut server = Server::new();
let mock = server
.mock("GET", "/test")
.match_header("X-Custom-Header", "TestValue")
.with_status(200)
.create();
let req = RequestDef {
req_id: 1,
method: "GET".to_string(),
endpoint: format!("{}/test", server.url()),
headers: vec![KeyValuePair {
key: "X-Custom-Header".to_string(),
value: "TestValue".to_string(),
}],
body: None,
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_ok());
mock.assert();
}
#[test]
fn test_run_request_task_with_body() {
let mut server = Server::new();
let mock = server
.mock("POST", "/test")
.match_body("test_body")
.with_status(201)
.create();
let req = RequestDef {
req_id: 1,
method: "POST".to_string(),
endpoint: format!("{}/test", server.url()),
headers: vec![],
body: Some(BodyDef::Text("test_body".to_string())),
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_ok());
assert_eq!(result.unwrap().status, 201);
mock.assert();
}
#[test]
fn test_run_request_task_with_url_encoded_body() {
let mut server = Server::new();
let mock = server
.mock("POST", "/test")
.match_body("key1=value1&key2=value2")
.with_status(200)
.create();
let req = RequestDef {
req_id: 1,
method: "POST".to_string(),
endpoint: format!("{}/test", server.url()),
headers: vec![],
body: Some(BodyDef::URLEncoded(vec![
KeyValuePair {
key: "key1".to_string(),
value: "value1".to_string(),
},
KeyValuePair {
key: "key2".to_string(),
value: "value2".to_string(),
},
])),
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_ok());
mock.assert();
}
#[test]
fn test_run_request_task_with_invalid_url() {
let req = RequestDef {
req_id: 1,
method: "GET".to_string(),
endpoint: "invalid_url".to_string(),
headers: vec![],
body: None,
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_err());
}
#[test]
fn test_run_request_task_with_form_data() {
let mut server = Server::new();
let mock = server
.mock("POST", "/test")
.match_header(
"content-type",
mockito::Matcher::Regex("multipart/form-data.*".to_string()),
)
.with_status(200)
.create();
let req = RequestDef {
req_id: 1,
method: "POST".to_string(),
endpoint: format!("{}/test", server.url()),
headers: vec![],
body: Some(BodyDef::FormData(vec![
FormDataEntry {
key: "text_field".to_string(),
value: FormDataValue::Text("text_value".to_string()),
},
FormDataEntry {
key: "file_field".to_string(),
value: FormDataValue::File {
filename: "test.txt".to_string(),
data: b"file_content".to_vec(),
mime: "text/plain".to_string(),
},
},
])),
validate_certs: false,
root_cert_bundle_files: vec![],
client_cert: None,
proxy: None,
};
let cancel_token = CancellationToken::new();
let result = run_request_task(&req, cancel_token);
assert!(result.is_ok());
mock.assert();
}
}

View File

@@ -0,0 +1,162 @@
pub mod controller;
pub mod error;
pub mod interceptor;
pub mod model;
pub mod route;
pub mod server;
pub mod state;
pub mod tray;
pub mod updater;
pub mod util;
use state::AppState;
use std::sync::Arc;
use tauri::{Listener, Manager, Url, WebviewWindowBuilder};
use tauri_plugin_updater::UpdaterExt;
use tokio_util::sync::CancellationToken;
#[tauri::command]
async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
Ok(state.active_registration_code.read().await.clone())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
let cancellation_token = CancellationToken::new();
let server_cancellation_token = cancellation_token.clone();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.setup(move |app| {
let app_handle = app.app_handle();
#[cfg(desktop)]
{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_autostart::ManagerExt;
let _ = app.handle().plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None
));
let autostart_manager = app.autolaunch();
println!("autostart enabled: {}", autostart_manager.is_enabled().unwrap());
if !autostart_manager.is_enabled().unwrap() {
let _ = autostart_manager.enable();
println!("autostart updated: {}", autostart_manager.is_enabled().unwrap());
}
};
#[cfg(desktop)]
{
// We use env variables to define the pubkey for installer to check
let updater_pub_key = option_env!("UPDATER_PUB_KEY");
let updater_url = option_env!("UPDATER_URL");
if let (Some(pub_key), Some(updater_url)) = (updater_pub_key, updater_url) {
let _ = app.handle()
.plugin(tauri_plugin_updater::Builder::new() .build());
let _ = app.handle()
.plugin(tauri_plugin_dialog::init());
let updater_url: Url = updater_url.parse().unwrap();
let updater = app.updater_builder()
.pubkey(pub_key)
.endpoints(
vec![updater_url]
)
.build()
.unwrap();
let app_handle_ref = app_handle.clone();
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
updater::check_and_install_updates(app_handle_ref, updater).await;
})
});
}
};
let app_state = Arc::new(AppState::new(app_handle.clone()));
app.manage(app_state.clone());
let server_cancellation_token = server_cancellation_token.clone();
let server_app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
server::run_server(app_state, server_cancellation_token, server_app_handle)
.await;
});
#[cfg(all(desktop))]
{
let handle = app.handle();
tray::create_tray(handle)?;
}
// Blocks the app from populating the macOS dock
#[cfg(target_os = "macos")]
{
app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory)
.unwrap();
};
let app_handle_ref = app_handle.clone();
app_handle.listen("registration_received", move |_| {
WebviewWindowBuilder::from_config(
&app_handle_ref,
&app_handle_ref.config().app.windows[0]
)
.unwrap()
.build()
.unwrap()
.show()
.unwrap();
});
Ok(())
})
.manage(cancellation_token)
.on_window_event(|window, event| {
match &event {
tauri::WindowEvent::CloseRequested { .. } => {
let app_state = window.state::<Arc<AppState>>();
let mut current_code =
app_state.active_registration_code.blocking_write();
if current_code.is_some() {
*current_code = None;
}
},
_ => {}
};
})
.invoke_handler(tauri::generate_handler![
get_otp
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, code, .. } => {
if code.is_none() || matches!(code, Some(0)) {
api.prevent_exit()
} else if code.is_some() {
let state = app_handle.state::<CancellationToken>();
state.cancel();
}
}
_ => {}
});
}

View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hoppscotch_agent_lib::run()
}

View File

@@ -0,0 +1,103 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct HandshakeResponse {
#[allow(non_snake_case)]
pub __hoppscotch__agent__: bool,
pub status: String,
pub agent_version: String
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfirmedRegistrationRequest {
pub registration: String,
/// base16 (lowercase) encoded public key shared by the client
/// to the agent so that the agent can establish a shared secret
/// which will be used to encrypt traffic between agent
/// and client after registration
pub client_public_key_b16: String
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthKeyResponse {
pub auth_key: String,
pub created_at: DateTime<Utc>,
/// base16 (lowercase) encoded public key shared by the
/// agent so that the client can establish a shared secret
/// which will be used to encrypt traffic between agent
/// and client after registration
pub agent_public_key_b16: String
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyValuePair {
pub key: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub enum FormDataValue {
Text(String),
File {
filename: String,
data: Vec<u8>,
mime: String,
},
}
#[derive(Debug, Deserialize)]
pub struct FormDataEntry {
pub key: String,
pub value: FormDataValue,
}
#[derive(Debug, Deserialize)]
pub enum BodyDef {
Text(String),
URLEncoded(Vec<KeyValuePair>),
FormData(Vec<FormDataEntry>),
}
#[derive(Debug, Deserialize)]
pub struct RequestDef {
pub req_id: usize,
pub method: String,
pub endpoint: String,
pub headers: Vec<KeyValuePair>,
pub body: Option<BodyDef>,
pub validate_certs: bool,
pub root_cert_bundle_files: Vec<Vec<u8>>,
pub client_cert: Option<ClientCertDef>,
pub proxy: Option<ProxyConfig>,
}
#[derive(Debug, Deserialize)]
pub struct ProxyConfig {
pub url: String,
}
#[derive(Debug, Deserialize)]
pub enum ClientCertDef {
PEMCert {
certificate_pem: Vec<u8>,
key_pem: Vec<u8>,
},
PFXCert {
certificate_pfx: Vec<u8>,
password: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RunRequestResponse {
pub status: u16,
pub status_text: String,
pub headers: Vec<KeyValuePair>,
pub data: Vec<u8>,
pub time_start_ms: u128,
pub time_end_ms: u128,
}

View File

@@ -0,0 +1,28 @@
use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc;
use tauri::AppHandle;
use crate::{controller, state::AppState};
pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
Router::new()
.route("/handshake", get(controller::handshake))
.route(
"/receive-registration",
post(controller::receive_registration),
)
.route(
"/verify-registration",
post(controller::verify_registration),
)
.route(
"/registered-handshake",
get(controller::registered_handshake),
)
.route("/request", post(controller::run_request))
.route("/cancel-request/:req_id", post(controller::cancel_request))
.with_state((state, app_handle))
}

View File

@@ -0,0 +1,34 @@
use axum::Router;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tower_http::cors::CorsLayer;
use crate::route;
use crate::state::AppState;
pub async fn run_server(
state: Arc<AppState>,
cancellation_token: CancellationToken,
app_handle: tauri::AppHandle,
) {
let cors = CorsLayer::permissive();
let app = Router::new()
.merge(route::route(state, app_handle))
.layer(cors);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
println!("Server running on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(async move {
cancellation_token.cancelled().await;
})
.await
.unwrap();
println!("Server shut down");
}

View File

@@ -0,0 +1,154 @@
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
use axum::body::Bytes;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tauri_plugin_store::StoreBuilder;
use tokio_util::sync::CancellationToken;
use tokio::sync::RwLock;
use crate::error::AppError;
/// Describes one registered app instance
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registration {
pub registered_at: DateTime<Utc>,
/// base16 (lowercase) encoded shared secret that the client
/// and agent established during registration that is used
/// to encrypt traffic between them
pub shared_secret_b16: String
}
#[derive(Default)]
pub struct AppState {
/// The active registration code that is being registered.
pub active_registration_code: RwLock<Option<String>>,
/// Cancellation Tokens for the running requests
pub cancellation_tokens: DashMap<usize, CancellationToken>,
/// Registrations against the agent, the key is the auth
/// token associated to the registration
registrations: DashMap<String, Registration>
}
impl AppState {
pub fn new(app_handle: tauri::AppHandle) -> Self {
let mut store = StoreBuilder::new("app_data.bin")
.build(app_handle);
let _ = store.load();
// Try loading and parsing registrations from the store, if that failed,
// load the default list
let registrations = store.get("registrations")
.and_then(|val| serde_json::from_value(val.clone()).ok())
.unwrap_or_else(|| DashMap::new());
Self {
active_registration_code: RwLock::new(None),
cancellation_tokens: DashMap::new(),
registrations
}
}
/// Gets you a readonly reference to the registrations list
/// NOTE: Although DashMap API allows you to update the list from an immutable
/// reference, you shouldn't do it for registrations as `update_registrations`
/// performs save operation that needs to be done and should be used instead
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
&self.registrations
}
/// Provides you an opportunity to update the registrations list
/// and also persists the data to the disk
pub fn update_registrations(
&self,
app_handle: tauri::AppHandle,
update_func: impl FnOnce(&DashMap<String, Registration>)
) -> Result<(), AppError> {
update_func(&self.registrations);
let mut store = StoreBuilder::new("app_data.bin")
.build(app_handle);
let _ = store.load();
store.delete("registrations")
.map_err(|_| AppError::RegistrationClearError)?;
store.insert("registrations".into(), serde_json::to_value(self.registrations.clone()).unwrap())
.map_err(|_| AppError::RegistrationInsertError)?;
store.save()
.map_err(|_| AppError::RegistrationSaveError)?;
Ok(())
}
pub async fn validate_registration(&self, registration: &str) -> bool {
match *self.active_registration_code.read().await {
Some(ref code) => code == registration,
None => false
}
}
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
self.cancellation_tokens.remove(&req_id)
}
pub fn add_cancellation_token(&self, req_id: usize, cancellation_tokens: CancellationToken) {
self.cancellation_tokens.insert(req_id, cancellation_tokens);
}
pub fn validate_access(&self, auth_key: &str) -> bool {
self.registrations.get(auth_key).is_some()
}
pub fn validate_access_and_get_data<T>(
&self,
auth_key: &str,
nonce: &str,
data: &Bytes
) -> Option<T>
where
T : DeserializeOwned
{
if let Some(registration) = self.registrations.get(auth_key) {
let key: [u8; 32] = base16::decode(&registration.shared_secret_b16)
.ok()?
[0..32]
.try_into()
.ok()?;
let nonce: [u8; 12] = base16::decode(nonce)
.ok()?
[0..12]
.try_into()
.ok()?;
let cipher = Aes256Gcm::new(&key.into());
let data = data
.iter()
.cloned()
.collect::<Vec<u8>>();
let plain_data = cipher.decrypt(&nonce.into(), data.as_slice())
.ok()?;
serde_json::from_reader(plain_data.as_slice())
.ok()
} else {
None
}
}
pub fn get_registration_info(&self, auth_key: &str) -> Option<Registration> {
self.registrations.get(auth_key)
.map(|reference| reference.value().clone())
}
}

View File

@@ -0,0 +1,90 @@
use crate::state::AppState;
use lazy_static::lazy_static;
use std::sync::Arc;
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
};
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
lazy_static! {
static ref TRAY_ICON: Image<'static> = Image::from_bytes(TRAY_ICON_DATA).unwrap();
}
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let clear_registrations = MenuItem::with_id(
app,
"clear_registrations",
"Clear Registrations",
true,
None::<&str>,
)?;
let pkg_info = app.package_info();
let app_name = pkg_info.name.clone();
let app_version = pkg_info.version.clone();
let app_name_item = MenuItem::with_id(app, "app_name", app_name, false, None::<&str>)?;
let app_version_item = MenuItem::with_id(
app,
"app_version",
format!("Version: {}", app_version),
false,
None::<&str>,
)?;
let menu = MenuBuilder::new(app)
.item(&app_name_item)
.item(&app_version_item)
.separator()
.item(&clear_registrations)
.item(&quit_i)
.build()?;
let _ = TrayIconBuilder::with_id("hopp-tray")
.tooltip("Hoppscotch Agent")
.icon(if cfg!(target_os = "macos") {
TRAY_ICON.clone()
} else {
app.default_window_icon().unwrap().clone()
})
.icon_as_template(cfg!(target_os = "macos"))
.menu(&menu)
.menu_on_left_click(true)
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => {
app.exit(-1);
}
"clear_registrations" => {
let app_state = app.state::<Arc<AppState>>();
app_state
.update_registrations(app.clone(), |regs| {
regs.clear();
})
.expect("Failed to clear registrations");
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app);
Ok(())
}

View File

@@ -0,0 +1,34 @@
#[cfg(desktop)]
pub async fn check_and_install_updates(app: tauri::AppHandle, updater: tauri_plugin_updater::Updater) {
use tauri::Manager;
use tauri_plugin_dialog::MessageDialogKind;
use tauri_plugin_dialog::DialogExt;
let update = updater.check().await;
if let Ok(Some(update)) = update {
let do_update = app.dialog()
.message(
format!(
"Update to {} is available!{}",
update.version,
update.body
.clone()
.map(|body| format!("\n\nRelease Notes: {}", body))
.unwrap_or("".into())
)
)
.title("Update Available")
.kind(MessageDialogKind::Info)
.ok_button_label("Update")
.cancel_button_label("Cancel")
.blocking_show();
if do_update {
let _ = update.download_and_install(|_, _| {}, || {}).await;
tauri::process::restart(&app.env());
}
}
}

View File

@@ -0,0 +1,47 @@
use aes_gcm::{aead::Aead, AeadCore, Aes256Gcm, KeyInit};
use axum::{body::Body, response::{IntoResponse, Response}};
use rand::rngs::OsRng;
use serde::Serialize;
pub fn get_status_text(status: u16) -> &'static str {
http::StatusCode::from_u16(status)
.map(|status| status.canonical_reason())
.unwrap_or(Some("Unknown Status"))
.unwrap_or("Unknown Status")
}
#[derive(Debug)]
pub struct EncryptedJson<T: Serialize> {
pub key_b16: String,
pub data: T
}
impl<T> IntoResponse for EncryptedJson<T> where T: Serialize {
fn into_response(self) -> Response {
let serialized_response = serde_json::to_vec(&self.data)
.expect("Failed serializing response to vec for encryption");
let key: [u8; 32] = base16::decode(&self.key_b16)
.unwrap()
[0..32]
.try_into()
.unwrap();
let cipher = Aes256Gcm::new(&key.into());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let nonce_b16 = base16::encode_lower(&nonce);
let encrypted_response = cipher.encrypt(&nonce, serialized_response.as_slice())
.expect("Failed encrypting response");
let mut response = Response::new(Body::from(encrypted_response));
let response_headers = response.headers_mut();
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
response_headers.insert("X-Hopp-Nonce", nonce_b16.parse().unwrap());
response
}
}

View File

@@ -0,0 +1,42 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "Hoppscotch Agent",
"version": "0.1.0",
"identifier": "io.hoppscotch.agent",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Hoppscotch Agent",
"width": 600,
"height": 400,
"center": true,
"resizable": false,
"maximizable": false,
"minimizable": false,
"focus": true,
"alwaysOnTop": true,
"create": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -0,0 +1,71 @@
<template>
<div class="font-sans min-h-screen flex flex-col">
<div class="p-5 flex flex-col flex-grow gap-y-2">
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
<p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please close the window if you did not initiate this request.
Do not close this window until the verification code is entered. Once done, this window will close by itself.
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">
{{ otpCode }}
</p>
</div>
<div class="border-t border-divider p-5 flex justify-between">
<HoppButtonSecondary
label="Copy Code"
outline
filled
:icon="copyIcon"
@click="copyCode"
/>
<HoppButtonPrimary
label="Close"
outline
@click="closeWindow"
/>
</div>
</div>
</template>
<script setup>
import { ref, markRaw, onMounted } from "vue"
import { HoppButtonPrimary, HoppButtonSecondary } from "@hoppscotch/ui"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import { useClipboard, refAutoReset } from "@vueuse/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { listen } from '@tauri-apps/api/event'
const { copy } = useClipboard()
const otpCode = ref("")
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
function copyCode() {
copyIcon.value = markRaw(IconCheck)
copy(otpCode.value)
}
function closeWindow() {
const currentWindow = getCurrentWindow()
currentWindow.close()
}
onMounted(async () => {
const currentWindow = getCurrentWindow()
currentWindow.setFocus(true);
currentWindow.setAlwaysOnTop(true);
otpCode.value = await invoke("get_otp", {})
await listen('registration_received', (event) => {
otpCode.value = event.payload
})
await listen('authenticated', () => {
closeWindow()
})
})
</script>

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import { plugin as HoppUI } from "@hoppscotch/ui"
import "@hoppscotch/ui/themes.css"
import "@hoppscotch/ui/style.css"
createApp(App)
.use(HoppUI)
.mount('#app')

View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@@ -0,0 +1,6 @@
import preset from '@hoppscotch/ui/ui-preset'
export default {
content: ['src/**/*.{vue,html}'],
presets: [preset]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,50 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import path from 'path';
import Icons from "unplugin-icons/vite";
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [
vue(),
Icons({})
],
css: {
postcss: {
plugins: [
tailwindcss,
autoprefixer,
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));

View File

@@ -39,9 +39,11 @@
"paste": "Paste",
"prettify": "Prettify",
"properties": "Properties",
"register": "Register",
"remove": "Remove",
"rename": "Rename",
"restore": "Restore",
"retry": "Retry",
"save": "Save",
"save_as_example": "Save as example",
"scroll_to_bottom": "Scroll to bottom",
@@ -59,12 +61,35 @@
"turn_off": "Turn off",
"turn_on": "Turn on",
"undo": "Undo",
"verify": "Verify",
"yes": "Yes"
},
"add": {
"new": "Add new",
"star": "Add star"
},
"agent": {
"registration_instruction": "Please register Hoppscotch Agent with your web client to continue.",
"enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration",
"otp_label": "Verification Code",
"processing": "Processing your request...",
"not_running": "The Hoppscotch Agent is not running. Please start the agent and click 'Retry'.",
"not_running_title": "Agent not detected",
"registration_title": "Agent registration",
"verify_ssl_certs": "Verify SSL Certificates",
"client_certs": "Client Certificates",
"use_http_proxy": "Use HTTP Proxy",
"proxy_capabilities": "Hoppscotch Agent supports HTTP/HTTPS/SOCKS proxies along with NTLM and Basic Auth in those proxies. Include the username and password for the proxy authentication in the URL itself.",
"add_cert_file": "Add Certificate File",
"add_client_cert": "Add Client Certificate",
"add_key_file": "Add Key File",
"domain": "Domain",
"cert": "Certificate",
"key": "Key",
"pfx_or_pkcs": "PFX/PKCS12",
"pfx_or_pkcs_file": "PFX/PKCS12 File",
"add_pfx_or_pkcs_file": "Add PFX/PKCS12 File"
},
"app": {
"chat_with_us": "Chat with us",
"contact_us": "Contact us",

View File

@@ -41,6 +41,8 @@
"@hoppscotch/ui": "0.2.0",
"@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0",
"@noble/curves": "1.6.0",
"@scure/base": "1.1.9",
"@shopify/lang-jsonc": "1.0.0",
"@unhead/vue": "1.8.8",
"@urql/core": "4.2.0",
@@ -50,8 +52,8 @@
"@vitejs/plugin-legacy": "4.1.1",
"@vueuse/core": "10.7.0",
"acorn-walk": "8.3.0",
"axios": "1.7.5",
"aws4fetch": "1.0.19",
"axios": "1.7.5",
"buffer": "6.0.3",
"cookie-es": "1.0.0",
"dioc": "3.0.2",

View File

@@ -1,11 +1,11 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
@@ -174,7 +174,6 @@ declare module '@vue/runtime-core' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -184,10 +183,8 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
@@ -196,6 +193,10 @@ declare module '@vue/runtime-core' {
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
InterceptorsAgentRegistrationModal: typeof import('./components/interceptors/agent/RegistrationModal.vue')['default']
InterceptorsAgentRootExt: typeof import('./components/interceptors/agent/RootExt.vue')['default']
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
@@ -209,17 +210,14 @@ declare module '@vue/runtime-core' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ModalsNativeCACertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeCACertificates.vue')['default']
ModalsNativeClientCertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertificates.vue')['default']
ModalsNativeClientCertsAdd: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertsAdd.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsNativeInterceptor: typeof import('./../../hoppscotch-selfhost-desktop/src/components/settings/NativeInterceptor.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
@@ -246,5 +244,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View File

@@ -0,0 +1,153 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('agent.client_certs')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col space-y-4">
<ul
v-if="certificateMap.size > 0"
class="mx-4 border border-dividerDark rounded"
>
<li
v-for="([domain, certificate], index) in certificateMap"
:key="domain"
class="flex border-dividerDark px-2 items-center justify-between"
:class="{ 'border-t border-dividerDark': index !== 0 }"
>
<div class="flex space-x-2">
<div class="truncate">
{{ domain }}
</div>
</div>
<div class="flex items-center space-x-1">
<div class="text-secondaryLight mr-2">
{{ "PEMCert" in certificate.cert ? "PEM" : "PFX/PKCS12" }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
:title="
certificate.enabled
? t('action.turn_off')
: t('action.turn_on')
"
color="green"
@click="toggleEntryEnabled(domain)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
:title="t('action.remove')"
color="red"
@click="deleteEntry(domain)"
/>
</div>
</li>
</ul>
<HoppButtonSecondary
class="mx-4"
:icon="IconPlus"
:label="t('agent.add_cert_file')"
filled
outline
@click="showAddModal = true"
/>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary :label="t('action.save')" @click="save" />
<HoppButtonSecondary
:label="t('action.cancel')"
filled
outline
@click="emit('hide-modal')"
/>
</div>
</template>
</HoppSmartModal>
<InterceptorsAgentModalNativeClientCertsAdd
:show="showAddModal"
:existing-domains="Array.from(certificateMap.keys())"
@hide-modal="showAddModal = false"
@save="saveCertificate"
/>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconTrash from "~icons/lucide/trash"
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { cloneDeep } from "lodash-es"
import {
ClientCertificateEntry,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const t = useI18n()
const nativeInterceptorService = useService(AgentInterceptorService)
const certificateMap = ref(new Map<string, ClientCertificateEntry>())
const showAddModal = ref(false)
watch(
() => props.show,
(show) => {
if (show) {
certificateMap.value = cloneDeep(
nativeInterceptorService.clientCertificates.value
)
}
}
)
function save() {
nativeInterceptorService.clientCertificates.value = cloneDeep(
certificateMap.value
)
emit("hide-modal")
}
function saveCertificate(cert: ClientCertificateEntry) {
certificateMap.value.set(cert.domain, cert)
}
function toggleEntryEnabled(domain: string) {
const certificate = certificateMap.value.get(domain)
if (certificate) {
certificateMap.value.set(domain, {
...certificate,
enabled: !certificate.enabled,
})
}
}
function deleteEntry(domain: string) {
certificateMap.value.delete(domain)
}
</script>

View File

@@ -0,0 +1,288 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('agent.add_client_cert')"
@close="emit('hide-modal')"
>
<template #body>
<div class="space-y-4">
<HoppSmartInput
v-model="domain"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('agent.domain')"
input-styles="input floating-input"
/>
<HoppSmartTabs v-model="selectedTab">
<HoppSmartTab :id="'pem'" :label="'PEM'">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label> {{ t("agent.cert") }} </label>
<HoppButtonSecondary
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
:loading="pemCert?.type === 'loading'"
:label="
pemCert?.type === 'loaded'
? pemCert.filename
: t('agent.add_cert_file')
"
filled
outline
@click="openFilePicker('pem_cert')"
/>
</div>
<div class="flex flex-col space-y-2">
<label> {{ t("agent.key") }} </label>
<HoppButtonSecondary
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
:loading="pemKey?.type === 'loading'"
:label="
pemKey?.type === 'loaded'
? pemKey.filename
: t('agent.add_key_file')
"
filled
outline
@click="openFilePicker('pem_key')"
/>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab :id="'pfx'" :label="t('agent.pfx_or_pkcs')">
<div class="p-4 space-y-6">
<div class="flex flex-col space-y-2">
<label> {{ t("agent.pfx_or_pkcs_file") }} </label>
<HoppButtonSecondary
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
:loading="pfxCert?.type === 'loading'"
:label="
pfxCert?.type === 'loaded'
? pfxCert.filename
: t('agent.add_pfx_or_pkcs_file')
"
filled
outline
@click="openFilePicker('pfx_cert')"
/>
</div>
<div class="border border-divider rounded">
<HoppSmartInput
v-model="pfxPassword"
:type="showPfxPassword ? 'text' : 'password'"
:label="t('authorization.password')"
input-styles="floating-input !border-0 "
:placeholder="' '"
>
<template #button>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
showPfxPassword
? t('hide.password')
: t('show.password')
"
:icon="showPfxPassword ? IconEye : IconEyeOff"
@click="showPfxPassword = !showPfxPassword"
/>
</template>
</HoppSmartInput>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
@click="save"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
filled
outline
@click="emit('hide-modal')"
/>
</div>
</template>
</HoppSmartModal>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconEyeOff from "~icons/lucide/eye-off"
import IconEye from "~icons/lucide/eye"
import IconFile from "~icons/lucide/file"
import { ref, watch, computed } from "vue"
import { useFileDialog } from "@vueuse/core"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { ClientCertificateEntry } from "~/platform/std/interceptors/agent"
const toast = useToast()
const props = defineProps<{
show: boolean
existingDomains: string[]
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "save", certificate: ClientCertificateEntry): void
}>()
type FileSelectorState =
| null
| { type: "loading" }
| { type: "loaded"; filename: string; data: Uint8Array }
const t = useI18n()
const domain = ref("")
const pemCert = ref<FileSelectorState>(null)
const pemKey = ref<FileSelectorState>(null)
const pfxCert = ref<FileSelectorState>(null)
const pfxPassword = ref("")
const showPfxPassword = ref(false)
const anyFileSelectorIsLoading = computed(
() =>
pemCert.value?.type === "loading" ||
pemKey.value?.type === "loading" ||
pfxCert.value?.type === "loading"
)
const currentlyPickingFile = ref<null | "pem_cert" | "pem_key" | "pfx_cert">(
null
)
const selectedTab = ref<"pem" | "pfx">("pem")
watch(
() => props.show,
(show) => {
if (!show) return
currentlyPickingFile.value = null
domain.value = ""
pemCert.value = null
pemKey.value = null
pfxCert.value = null
pfxPassword.value = ""
showPfxPassword.value = false
selectedTab.value = "pem"
}
)
const certificate = computed<ClientCertificateEntry | null>(() => {
if (selectedTab.value === "pem") {
if (pemCert.value?.type === "loaded" && pemKey.value?.type === "loaded") {
return <ClientCertificateEntry>{
domain: domain.value,
enabled: true,
cert: {
PEMCert: {
certificate_filename: pemCert.value.filename,
certificate_pem: pemCert.value.data,
key_filename: pemKey.value.filename,
key_pem: pemKey.value.data,
},
},
}
}
} else {
if (pfxCert.value?.type === "loaded") {
return <ClientCertificateEntry>{
domain: domain.value.trim(),
enabled: true,
cert: {
PFXCert: {
certificate_filename: pfxCert.value.filename,
certificate_pfx: pfxCert.value.data,
password: pfxPassword.value,
},
},
}
}
}
return null
})
const isValidCertificate = computed(() => {
if (certificate.value === null) return false
if (props.existingDomains.includes(certificate.value.domain)) {
toast.error("A certificate for this domain already exists")
return false
}
return ClientCertificateEntry.safeParse(certificate.value).success
})
const {
open: openFileDialog,
reset: resetFilePicker,
onChange: onFilePickerChange,
} = useFileDialog({
reset: true,
multiple: false,
})
onFilePickerChange(async (files) => {
if (!files) return
const file = files.item(0)
if (!file) return
if (currentlyPickingFile.value === "pem_cert") {
pemCert.value = { type: "loading" }
} else if (currentlyPickingFile.value === "pem_key") {
pemKey.value = { type: "loading" }
} else if (currentlyPickingFile.value === "pfx_cert") {
pfxCert.value = { type: "loading" }
}
const data = new Uint8Array(await file.arrayBuffer())
if (currentlyPickingFile.value === "pem_cert") {
pemCert.value = { type: "loaded", filename: file.name, data }
} else if (currentlyPickingFile.value === "pem_key") {
pemKey.value = { type: "loaded", filename: file.name, data }
} else if (currentlyPickingFile.value === "pfx_cert") {
pfxCert.value = { type: "loaded", filename: file.name, data }
}
currentlyPickingFile.value = null
resetFilePicker()
})
function openFilePicker(type: "pem_cert" | "pem_key" | "pfx_cert") {
currentlyPickingFile.value = type
openFileDialog()
}
function save() {
if (certificate.value) {
emit("save", certificate.value)
emit("hide-modal")
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<!-- TODO: i18n -->
<HoppSmartModal
v-if="show"
dialog
styles="sm:max-w-md"
:title="modalTitle"
@close="hideModal"
>
<template #body>
<div class="space-y-4">
<p v-if="status === 'agent_not_running'" class="text-secondaryLight">
{{ t("agent.not_running") }}
</p>
<template v-else-if="status === 'registration_required'">
<p
v-if="registrationStatus === 'initial'"
class="text-secondaryLight"
>
{{ t("agent.registration_instruction") }}
</p>
<template v-else-if="registrationStatus === 'otp_required'">
<p class="text-secondaryLight">
{{ t("agent.enter_otp_instruction") }}
</p>
<HoppSmartInput
v-model="userEnteredOTP"
placeholder=" "
:label="t('agent.otp_label')"
input-styles="input floating-input"
/>
</template>
<div
v-else-if="isRegistrationLoading"
class="flex items-center space-x-2"
>
<HoppSmartSpinner />
<p class="text-secondaryLight">{{ t("agent.processing") }}</p>
</div>
</template>
</div>
</template>
<template #footer>
<div class="flex justify-start flex-1">
<HoppButtonPrimary
:label="primaryButtonLabel"
:loading="isRegistrationLoading"
@click="primaryActionHandler"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
class="ml-2"
filled
outline
@click="hideModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const userEnteredOTP = ref("")
const props = defineProps<{
show: boolean
status: "agent_not_running" | "registration_required" | "hidden"
registrationStatus: "initial" | "otp_required" | "loading"
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "register"): void
(e: "verify", otp: string): void
(e: "retry-connection"): void
}>()
const modalTitle = computed(() => {
switch (props.status) {
case "agent_not_running":
return t("agent.not_running_title")
case "registration_required":
return t("agent.registration_title")
default:
return ""
}
})
const isRegistrationLoading = computed(
() => props.registrationStatus === "loading"
)
const primaryButtonLabel = computed(() => {
if (isRegistrationLoading.value) {
return t("state.loading")
}
if (props.status === "agent_not_running") {
return t("action.retry")
}
if (props.status === "registration_required") {
if (props.registrationStatus === "initial") {
return t("action.register")
}
if (props.registrationStatus === "otp_required") {
return t("action.verify")
}
}
return ""
})
const primaryActionHandler = () => {
if (props.status === "agent_not_running") {
return emit("retry-connection")
}
if (props.status === "registration_required") {
if (props.registrationStatus === "initial") {
return emit("register")
}
if (props.registrationStatus === "otp_required") {
return emit("verify", userEnteredOTP.value)
}
}
return null
}
const hideModal = () => emit("hide-modal")
</script>

View File

@@ -0,0 +1,100 @@
<template>
<InterceptorsAgentRegistrationModal
:show="showModal"
:status="modalStatus"
:registration-status="registrationStatus"
@hide-modal="hideModal"
@register="register"
@verify="verifyOTP"
@retry-connection="checkAgentStatus(true)"
/>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { AgentInterceptorService } from "~/platform/std/interceptors/agent"
import { ref, onMounted, computed, watch } from "vue"
import { useToast } from "@composables/toast"
import { InterceptorService } from "~/services/interceptor.service"
import { defineActionHandler } from "~/helpers/actions"
// TODO: Move as much as logic as possible to AgentInterceptorService
const interceptorService = useService(InterceptorService) // TODO: Try to remove dependency to InterceptorService
const agentService = useService(AgentInterceptorService)
const showModal = ref(false)
const toast = useToast()
const modalStatus = computed(() => {
if (!agentService.isAgentRunning.value) return "agent_not_running"
if (!agentService.isAuthKeyPresent()) return "registration_required"
return "hidden"
})
const registrationStatus = ref<"initial" | "otp_required" | "loading">(
"initial"
)
async function checkAgentStatus(isRetry = false) {
if (
interceptorService.currentInterceptor.value?.interceptorID ===
agentService.interceptorID
) {
await agentService.checkAgentStatus()
updateModalVisibility()
if (isRetry && !agentService.isAgentRunning.value) {
toast.error("Agent is not running.")
}
}
}
watch(interceptorService.currentInterceptor, () => {
checkAgentStatus()
})
function updateModalVisibility() {
showModal.value = modalStatus.value !== "hidden"
if (showModal.value && modalStatus.value === "registration_required") {
registrationStatus.value = "initial"
}
}
onMounted(async () => {
await checkAgentStatus()
})
function hideModal() {
showModal.value = false
}
async function register() {
registrationStatus.value = "loading"
try {
await agentService.initiateRegistration()
registrationStatus.value = "otp_required"
} catch (error) {
toast.error("Failed to initiate registration. Please try again.")
registrationStatus.value = "initial"
}
}
async function verifyOTP(otp: string) {
registrationStatus.value = "loading"
try {
await agentService.verifyRegistration(otp)
toast.success("Registration successful!")
hideModal()
} catch (error) {
toast.error("Failed to verify OTP. Please try again.")
registrationStatus.value = "otp_required"
}
}
defineActionHandler("agent.open-registration-modal", () => {
if (!showModal.value) {
showModal.value = true
registrationStatus.value = "initial"
}
})
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
:on="allowSSLVerification"
@change="allowSSLVerification = !allowSSLVerification"
/>
{{ t("agent.verify_ssl_certs") }}
</div>
<div class="flex space-x-4">
<!--
<HoppButtonSecondary
:icon="IconLucideFileBadge"
:label="'CA Certificates'"
outline
@click="showCACertificatesModal = true"
/>
-->
<!--
<HoppButtonSecondary
:icon="IconLucideFileKey"
:label="t('agent.client_certs')"
outline
@click="showClientCertificatesModal = true"
/>
-->
</div>
<!--
<ModalsNativeCACertificates
:show="showCACertificatesModal"
@hide-modal="showCACertificatesModal = false"
/>
-->
<!--
<InterceptorsAgentModalNativeClientCertificates
:show="showClientCertificatesModal"
@hide-modal="showClientCertificatesModal = false"
/>
-->
<div class="pt-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
{{ t("agent.use_http_proxy") }}
</div>
<HoppSmartInput
v-if="allowProxy"
v-model="proxyURL"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
/>
<p class="my-1 text-secondaryLight">
{{ t("agent.proxy_capabilities") }}
</p>
</div>
</div>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
// import IconLucideFileKey from "~icons/lucide/file-key"
import { useService } from "dioc/vue"
import {
RequestDef,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
import { syncRef } from "@vueuse/core"
type RequestProxyInfo = RequestDef["proxy"]
const t = useI18n()
const agentInterceptorService = useService(AgentInterceptorService)
const allowSSLVerification = agentInterceptorService.validateCerts
// const showCACertificatesModal = ref(false)
// const showClientCertificatesModal = ref(false)
const allowProxy = ref(false)
const proxyURL = ref("")
const proxyInfo = computed<RequestProxyInfo>({
get() {
if (allowProxy.value) {
return {
url: proxyURL.value,
}
}
return undefined
},
set(newData) {
if (newData) {
allowProxy.value = true
proxyURL.value = newData.url
} else {
allowProxy.value = false
}
},
})
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" })
</script>

View File

@@ -78,6 +78,7 @@ export type HoppAction =
| "share.request" // Share REST request
| "tab.duplicate-tab" // Duplicate REST request
| "gql.request.open" // Open GraphQL request
| "agent.open-registration-modal" // Open Hoppscotch Agent registration modal
/**
* Defines the arguments, if present for a given type that is required to be passed on

View File

@@ -38,7 +38,10 @@ export function getSuitableLenses(response: HoppRESTResponse): Lens[] {
)
return []
const contentType = response.headers.find((h) => h.key === "content-type")
// Lowercase the content-type key because HTTP Headers are case-insensitive by spec
const contentType = response.headers.find(
(h) => h.key.toLowerCase() === "content-type"
)
if (!contentType) return [rawLens]

View File

@@ -57,6 +57,14 @@
@hide-modal="showSupport = false"
/>
<AppOptions v-else :show="showSupport" @hide-modal="showSupport = false" />
<!-- Let additional stuff be registered -->
<template
v-for="(component, index) in rootExtensionComponents"
:key="index"
>
<component :is="component" />
</template>
</div>
</template>
@@ -78,6 +86,7 @@ import { platform } from "~/platform"
import { HoppSpotlightSessionEventData } from "~/platform/analytics"
import { PersistenceService } from "~/services/persistence"
import { SpotlightService } from "~/services/spotlight"
import { UIExtensionService } from "~/services/ui-extension.service"
const router = useRouter()
@@ -96,6 +105,9 @@ const t = useI18n()
const persistenceService = useService(PersistenceService)
const spotlightService = useService(SpotlightService)
const uiExtensionService = useService(UIExtensionService)
const rootExtensionComponents = uiExtensionService.rootUIExtensionComponents
const HAS_OPENED_SPOTLIGHT = useSetting("HAS_OPENED_SPOTLIGHT")

View File

@@ -42,10 +42,16 @@ export class ExtensionInspectorService extends Service implements Inspector {
() => currentExtensionStatus.value === "available"
)
const EXTENSIONS_ENABLED = computed(
() => this.interceptorService.currentInterceptorID.value === "extension"
const activeInterceptor = computed(
() => this.interceptorService.currentInterceptorID.value
)
const EXTENSION_ENABLED = computed(
() => activeInterceptor.value === "extension"
)
const AGENT_ENABLED = computed(() => activeInterceptor.value === "agent")
return computed(() => {
const results: InspectorResult[] = []
@@ -56,9 +62,11 @@ export class ExtensionInspectorService extends Service implements Inspector {
url.includes(host)
)
// Prompt the user to install or enable the extension via inspector if the endpoint is `localhost`, and an interceptor other than `Agent` is active
if (
isContainLocalhost &&
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
!AGENT_ENABLED.value &&
(!EXTENSION_ENABLED.value || !isExtensionInstalled.value)
) {
let text
@@ -68,7 +76,7 @@ export class ExtensionInspectorService extends Service implements Inspector {
} else {
text = this.t("inspections.url.extension_not_installed")
}
} else if (!EXTENSIONS_ENABLED.value) {
} else if (!EXTENSION_ENABLED.value) {
text = this.t("inspections.url.extention_not_enabled")
} else {
text = this.t("inspections.url.localhost")

Some files were not shown because too many files have changed in this diff Show More