Compare commits
3 Commits
fix/precis
...
2024.3.3-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0805fafe | ||
|
|
26b4f64824 | ||
|
|
4156551b24 |
@@ -47,6 +47,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP
|
||||
|
||||
|
||||
# Base URLs
|
||||
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
|
||||
VITE_BASE_URL=http://localhost:3000
|
||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||
VITE_ADMIN_URL=http://localhost:3100
|
||||
|
||||
@@ -14,3 +14,21 @@ Since TypeScript cannot handle type information for `.vue` imports, they are shi
|
||||
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).
|
||||
|
||||
## Building
|
||||
|
||||
### Prequisites
|
||||
- Install Rust: `curl https://sh.rustup.rs -sSf | sh`
|
||||
- If you're on Mac, remove the installation from brew first if any (brew uninstall rust && brew cleanup)
|
||||
- libsoup2.4-dev installed if Linux: `sudo apt install libsoup2.4-dev`
|
||||
- Node v18.20 installed and currently active if you're using nvm
|
||||
|
||||
### Build Instruction
|
||||
|
||||
1. Install latest pnpm `curl -fsSL https://get.pnpm.io/install.sh | sh -` or upgrade `pnpm add -g pnpm`
|
||||
2. Setup the .env of the root project folder, you should deploy the self hosted backend first
|
||||
3. Run `pnpm install` on root project folder
|
||||
4. Run `pnpm dev:gql-codegen` on this folder
|
||||
5. Run `pnpm tauri dev` to run debug mode (optional)
|
||||
6. Run `pnpm tauri build` to build release mode
|
||||
- `pnpm tauri build --target universal-apple-darwin` for Mac
|
||||
@@ -17,6 +17,7 @@
|
||||
"@fontsource-variable/material-symbols-rounded": "5.0.16",
|
||||
"@fontsource-variable/roboto-mono": "5.0.16",
|
||||
"@hoppscotch/common": "workspace:^",
|
||||
"@hoppscotch/data": "workspace:^",
|
||||
"@platform/auth": "0.1.106",
|
||||
"@tauri-apps/api": "1.5.1",
|
||||
"@tauri-apps/cli": "1.5.6",
|
||||
@@ -36,7 +37,13 @@
|
||||
"tauri-plugin-store-api": "0.0.0",
|
||||
"util": "0.12.5",
|
||||
"vue": "3.3.9",
|
||||
"workbox-window": "6.6.0"
|
||||
"workbox-window": "6.6.0",
|
||||
"zod": "3.22.4",
|
||||
"@urql/core": "^4.1.1",
|
||||
"cookie": "^0.5.0",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tauri-plugin-websocket-api": "github:tauri-apps/tauri-plugin-websocket#v1",
|
||||
"wonka": "^6.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/add": "5.0.0",
|
||||
@@ -76,6 +83,8 @@
|
||||
"vite-plugin-pwa": "0.13.1",
|
||||
"vite-plugin-static-copy": "0.12.0",
|
||||
"vite-plugin-vue-layouts": "0.7.0",
|
||||
"vue-tsc": "1.8.8"
|
||||
"vue-tsc": "1.8.8",
|
||||
"@types/cookie": "^0.5.1"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,13 @@ tauri = { version = "1.5.3", features = [
|
||||
] }
|
||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
|
||||
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-window-state = "0.1.0"
|
||||
reqwest = "0.11.22"
|
||||
serde_json = "1.0.108"
|
||||
url = "2.5.0"
|
||||
hex_color = "3.0.0"
|
||||
time = "0.3.36"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.25.0"
|
||||
|
||||
@@ -21,6 +21,7 @@ fn main() {
|
||||
tauri_plugin_deep_link::prepare("io.hoppscotch.desktop");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.setup(|app| {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"targets": "all"
|
||||
},
|
||||
"security": {
|
||||
"csp": "none"
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
|
||||
168
packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts
Normal file
168
packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ref } from "vue"
|
||||
import { makeResult, makeErrorResult, Exchange, Operation } from "@urql/core"
|
||||
import { makeFetchBody, makeFetchOptions } from "@urql/core/internal"
|
||||
import { filter, make, merge, mergeMap, pipe, takeUntil, map } from "wonka"
|
||||
import { gqlClientError$ } from "@hoppscotch/common/helpers/backend/GQLClient"
|
||||
import { Store } from "tauri-plugin-store-api"
|
||||
import { Body, getClient } from "@tauri-apps/api/http"
|
||||
import { parse, serialize } from "cookie"
|
||||
import { SubscriptionClient } from "subscriptions-transport-ws"
|
||||
import { platform } from "@hoppscotch/common/platform"
|
||||
import WSWrapper from "./ws_wrapper"
|
||||
|
||||
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
|
||||
|
||||
export async function addCookieToFetchHeaders(
|
||||
store: Store,
|
||||
headers: HeadersInit = {}
|
||||
) {
|
||||
try {
|
||||
const accessToken = await store.get<{ value: string }>("access_token")
|
||||
const refreshToken = await store.get<{ value: string }>("refresh_token")
|
||||
|
||||
if (accessToken?.value && refreshToken?.value) {
|
||||
// Assert headers as an indexable type
|
||||
const headersIndexable = headers as { [key: string]: string }
|
||||
const existingCookies = parse(headersIndexable["Cookie"] || "")
|
||||
|
||||
if (!existingCookies.access_token) {
|
||||
existingCookies.access_token = accessToken.value
|
||||
}
|
||||
if (!existingCookies.refresh_token) {
|
||||
existingCookies.refresh_token = refreshToken.value
|
||||
}
|
||||
|
||||
// Serialize the cookies back into the headers
|
||||
const serializedCookies = Object.entries(existingCookies)
|
||||
.map(([name, value]) => serialize(name, value))
|
||||
.join("; ")
|
||||
headersIndexable["Cookie"] = serializedCookies
|
||||
}
|
||||
|
||||
return headers
|
||||
} catch (error) {
|
||||
console.error("error while injecting cookie")
|
||||
}
|
||||
}
|
||||
|
||||
function createHttpSource(operation: Operation, store: Store) {
|
||||
return make(({ next, complete }) => {
|
||||
getClient().then(async (httpClient) => {
|
||||
const fetchOptions = makeFetchOptions(operation)
|
||||
let headers = fetchOptions.headers
|
||||
headers = await addCookieToFetchHeaders(store, headers)
|
||||
|
||||
const fetchBody = makeFetchBody(operation)
|
||||
httpClient
|
||||
.post(operation.context.url, Body.json(fetchBody), {
|
||||
headers,
|
||||
})
|
||||
.then((result) => {
|
||||
next(result.data)
|
||||
complete()
|
||||
})
|
||||
.catch((error) => {
|
||||
next(makeErrorResult(operation, error))
|
||||
complete()
|
||||
})
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
}
|
||||
|
||||
export const tauriGQLFetchExchange =
|
||||
(store: Store): Exchange =>
|
||||
({ forward }) =>
|
||||
(ops$) => {
|
||||
const subscriptionResults$ = pipe(
|
||||
ops$,
|
||||
filter((op) => op.kind === "query" || op.kind === "mutation"),
|
||||
mergeMap((operation) => {
|
||||
const { key, context } = operation
|
||||
|
||||
const teardown$ = pipe(
|
||||
ops$,
|
||||
filter((op: Operation) => op.kind === "teardown" && op.key === key)
|
||||
)
|
||||
|
||||
const source = createHttpSource(operation, store)
|
||||
|
||||
return pipe(
|
||||
source,
|
||||
takeUntil(teardown$),
|
||||
map((result) => makeResult(operation, result as any))
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const forward$ = pipe(
|
||||
ops$,
|
||||
filter(
|
||||
(op: Operation) => op.kind === "teardown" || op.kind != "subscription"
|
||||
),
|
||||
forward
|
||||
)
|
||||
|
||||
return merge([subscriptionResults$, forward$])
|
||||
}
|
||||
|
||||
const createSubscriptionClient = () => {
|
||||
return new SubscriptionClient(
|
||||
import.meta.env.VITE_BACKEND_WS_URL,
|
||||
{
|
||||
reconnect: true,
|
||||
connectionParams: () => platform.auth.getBackendHeaders(),
|
||||
connectionCallback(error) {
|
||||
if (error?.length > 0) {
|
||||
gqlClientError$.next({
|
||||
type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT",
|
||||
errors: error,
|
||||
})
|
||||
}
|
||||
},
|
||||
wsOptionArguments: [
|
||||
{
|
||||
store: new Store(APP_DATA_PATH),
|
||||
},
|
||||
],
|
||||
},
|
||||
WSWrapper
|
||||
)
|
||||
}
|
||||
|
||||
let subscriptionClient: SubscriptionClient | null
|
||||
|
||||
const isBackendGQLEventAdded = ref(false)
|
||||
|
||||
const resetSubscriptionClient = () => {
|
||||
if (subscriptionClient) {
|
||||
subscriptionClient.close()
|
||||
}
|
||||
subscriptionClient = createSubscriptionClient()
|
||||
if (!isBackendGQLEventAdded.value) {
|
||||
subscriptionClient.onConnected(() => {
|
||||
platform.auth.onBackendGQLClientShouldReconnect(() => {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser && subscriptionClient) {
|
||||
subscriptionClient?.client?.close()
|
||||
}
|
||||
|
||||
if (currentUser && !subscriptionClient) {
|
||||
resetSubscriptionClient()
|
||||
}
|
||||
|
||||
if (!currentUser && subscriptionClient) {
|
||||
subscriptionClient.close()
|
||||
resetSubscriptionClient()
|
||||
}
|
||||
})
|
||||
})
|
||||
isBackendGQLEventAdded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
export const getSubscriptionClient = () => {
|
||||
if (!subscriptionClient) resetSubscriptionClient()
|
||||
return subscriptionClient
|
||||
}
|
||||
154
packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts
Normal file
154
packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Store } from "tauri-plugin-store-api"
|
||||
import TauriWebSocket, {
|
||||
Message,
|
||||
ConnectionConfig,
|
||||
} from "tauri-plugin-websocket-api"
|
||||
import { addCookieToFetchHeaders } from "./GQLClient"
|
||||
|
||||
/**
|
||||
* This is a wrapper around tauri-plugin-websocket-api with cookie injection support. This is required because
|
||||
* subscriptions-transport-ws client expects a custom websocket implementation in the shape of native browser WebSocket.
|
||||
*/
|
||||
|
||||
export default class WebSocketWrapper extends EventTarget implements WebSocket {
|
||||
public client: TauriWebSocket | undefined
|
||||
private tauriWebSocketConfig:
|
||||
| (ConnectionConfig & { store: Store })
|
||||
| undefined
|
||||
private isConnected: boolean = false
|
||||
binaryType: BinaryType = "blob"
|
||||
extensions = ""
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null
|
||||
onerror: ((this: WebSocket, ev: Event) => any) | null = null
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null
|
||||
onopen: ((this: WebSocket, ev: Event) => any) | null = null
|
||||
protocol = ""
|
||||
url: string
|
||||
|
||||
public static readonly CONNECTING = 0
|
||||
public static readonly OPEN = 1
|
||||
public static readonly CLOSING = 2
|
||||
public static readonly CLOSED = 3
|
||||
|
||||
readonly CONNECTING = 0
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
protocols?: string | string[],
|
||||
config?: ConnectionConfig & { store: Store }
|
||||
) {
|
||||
super()
|
||||
this.url = url
|
||||
this.tauriWebSocketConfig = config
|
||||
this.setup()
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
if (this.tauriWebSocketConfig?.store) {
|
||||
const headersStringified =
|
||||
this.tauriWebSocketConfig.headers || ("{}" as any)
|
||||
let headers = JSON.parse(headersStringified)
|
||||
headers = await addCookieToFetchHeaders(
|
||||
this.tauriWebSocketConfig.store,
|
||||
headers
|
||||
)
|
||||
this.tauriWebSocketConfig = {
|
||||
...this.tauriWebSocketConfig,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
this.client = await TauriWebSocket.connect(this.url, {
|
||||
headers: {
|
||||
"sec-websocket-protocol": "graphql-ws",
|
||||
...this.tauriWebSocketConfig?.headers,
|
||||
},
|
||||
}).catch((error) => {
|
||||
this.isConnected = false
|
||||
if (this.onerror) {
|
||||
this.onerror(new Event("error"))
|
||||
}
|
||||
throw new Error(error)
|
||||
})
|
||||
|
||||
this.isConnected = true
|
||||
|
||||
this.client.addListener(this.handleMessage.bind(this))
|
||||
if (this.onopen) {
|
||||
this.onopen(new Event("open"))
|
||||
}
|
||||
}
|
||||
|
||||
get readyState(): number {
|
||||
return this.client
|
||||
? this.isConnected
|
||||
? this.OPEN
|
||||
: this.CLOSED
|
||||
: this.CONNECTING
|
||||
}
|
||||
|
||||
get bufferedAmount(): number {
|
||||
// TODO implement
|
||||
return 0
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
this.client?.disconnect().then(() => {
|
||||
if (this.onclose) {
|
||||
this.onclose(new CloseEvent("close"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
if (
|
||||
typeof data === "string" ||
|
||||
data instanceof ArrayBuffer ||
|
||||
data instanceof Blob
|
||||
) {
|
||||
this.client?.send(data as string).catch((error) => {
|
||||
console.error("error while sending data", data)
|
||||
if (this.onerror) {
|
||||
this.onerror(new Event("error"))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// TODO implement, drop the record for now
|
||||
console.warn(
|
||||
"WebSocketWrapper.send() not implemented for non-string data"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: Message): void {
|
||||
switch (message.type) {
|
||||
case "Close": {
|
||||
if (this.onclose) {
|
||||
this.onclose(new CloseEvent("close"))
|
||||
}
|
||||
return
|
||||
}
|
||||
case "Ping": {
|
||||
this.client?.send("Pong").catch((error) => {
|
||||
console.error("error while sending Pong data", message)
|
||||
if (this.onerror) {
|
||||
this.onerror(new Event("error"))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
default: {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(
|
||||
new MessageEvent("message", {
|
||||
data: message.data,
|
||||
origin: this.url,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getService } from "@hoppscotch/common/modules/dioc"
|
||||
import axios from "axios"
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthPlatformDef,
|
||||
@@ -13,15 +14,50 @@ import { open } from '@tauri-apps/api/shell'
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { Store } from "tauri-plugin-store-api"
|
||||
import { Ref, ref, watch } from "vue"
|
||||
import { z } from "zod"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { subscriptionExchange } from "@urql/core"
|
||||
import {
|
||||
getSubscriptionClient,
|
||||
tauriGQLFetchExchange,
|
||||
} from "../helpers/GQLClient"
|
||||
|
||||
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
|
||||
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
|
||||
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
|
||||
export const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
|
||||
|
||||
const persistenceService = getService(PersistenceService)
|
||||
|
||||
const expectedAllowedProvidersSchema = z.object({
|
||||
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
|
||||
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers
|
||||
providers: z.array(z.string()),
|
||||
})
|
||||
|
||||
|
||||
export const getAllowedAuthProviders = async () => {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
)
|
||||
|
||||
const parseResult = expectedAllowedProvidersSchema.safeParse(res.data)
|
||||
|
||||
if (!parseResult.success) {
|
||||
return E.left("SOMETHING_WENT_WRONG")
|
||||
}
|
||||
|
||||
return E.right(parseResult.data.providers)
|
||||
} catch (_) {
|
||||
return E.left("SOMETHING_WENT_WRONG")
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
let client = await getClient();
|
||||
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
|
||||
@@ -37,7 +73,7 @@ async function signInUserWithGithubFB() {
|
||||
}
|
||||
|
||||
async function signInUserWithGoogleFB() {
|
||||
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
|
||||
await open(`${import.meta.env.VITE_BACKEND_LOGIN_API_URL}/authenticate`);
|
||||
}
|
||||
|
||||
async function signInUserWithMicrosoftFB() {
|
||||
@@ -224,6 +260,15 @@ export const def: AuthPlatformDef = {
|
||||
},
|
||||
getGQLClientOptions() {
|
||||
return {
|
||||
exchanges: [
|
||||
subscriptionExchange({
|
||||
forwardSubscription(fetchBody) {
|
||||
const subscriptionClient = getSubscriptionClient()
|
||||
return subscriptionClient!.request(fetchBody)
|
||||
},
|
||||
}),
|
||||
tauriGQLFetchExchange(new Store(APP_DATA_PATH)),
|
||||
],
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
@@ -371,4 +416,5 @@ export const def: AuthPlatformDef = {
|
||||
event: "logout",
|
||||
})
|
||||
},
|
||||
getAllowedAuthProviders,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user