feat(desktop): portable phase-3: instance manager (#5421)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Shreyas 2025-11-25 18:09:18 +05:30 committed by GitHub
parent 017341928c
commit f834cc87d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
218 changed files with 5101 additions and 16784 deletions

View file

@ -3,10 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1738772960,
"lastModified": 1761922975,
"owner": "cachix",
"repo": "devenv",
"rev": "7f756cdf3fbb01cab243dcec4de0ca94e6aaa2af",
"rev": "c9f0b47815a4895fadac87812de8a4de27e0ace1",
"type": "github"
},
"original": {
@ -24,10 +24,10 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1738737274,
"lastModified": 1762238689,
"owner": "nix-community",
"repo": "fenix",
"rev": "f82de9980822f3b1efcf54944939b1d514386827",
"rev": "0f94d1e67ea9ef983a9b5caf9c14bc52ae2eac44",
"type": "github"
},
"original": {
@ -39,10 +39,10 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"lastModified": 1761588595,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
@ -60,10 +60,10 @@
]
},
"locked": {
"lastModified": 1737465171,
"lastModified": 1760663237,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
@ -94,10 +94,10 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1738734093,
"lastModified": 1762156382,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5b2753b0356d1c951d7a3ef1d086ba5a71fff43c",
"rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
"type": "github"
},
"original": {
@ -115,16 +115,17 @@
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
],
"rust-overlay": "rust-overlay"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1738754241,
"lastModified": 1762201112,
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "ca47cddc31ae76a05e8709ed4aec805c5ef741d3",
"rev": "132d3338f4526b5c71046e5dc7ddf800e279daf4",
"type": "github"
},
"original": {
@ -133,6 +134,25 @@
"repo": "rust-analyzer",
"type": "github"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1762223900,
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "cfe1598d69a42a5edb204770e71b8df77efef2c3",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",

View file

@ -7,12 +7,7 @@ let
else pkgs;
darwinPackages = with pkgs; [
darwin.apple_sdk.frameworks.Security
darwin.apple_sdk.frameworks.CoreServices
darwin.apple_sdk.frameworks.CoreFoundation
darwin.apple_sdk.frameworks.Foundation
darwin.apple_sdk.frameworks.AppKit
darwin.apple_sdk.frameworks.WebKit
apple-sdk
];
linuxPackages = with pkgs; [
@ -172,6 +167,10 @@ in {
npm.enable = true;
pnpm.enable = true;
};
go = {
enable = true;
package = pkgs.go_1_24;
};
rust = {
enable = true;
channel = "nightly";

View file

@ -1,23 +1,14 @@
# 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.
nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable
rust-overlay:
url: github:oxalica/rust-overlay
inputs:
nixpkgs:
follows: nixpkgs
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

@ -47,8 +47,14 @@ module.exports = {
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars":
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/default": "off",

View file

@ -877,7 +877,28 @@
"add_new": "Add a new instance",
"confirm_remove": "Confirm Removal",
"remove_warning": "Are you sure you want to remove this instance?",
"clear_cached_bundles": "Clear cached bundles"
"clear_cached_bundles": "Clear cached bundles",
"opening_add_modal": "Opening add instance dialog",
"closed_add_modal": "Add instance dialog closed",
"cancelled_removal": "Instance removal cancelled",
"connection_cancelled": "Connection cancelled by pre-connect validation",
"post_connect_completed": "Post-connection setup completed",
"connecting": "Connecting to instance...",
"confirm_removal": "Confirm removal of instance",
"removal_cancelled": "Instance removal cancelled by pre-removal validation",
"post_remove_completed": "Post-removal cleanup completed",
"removing": "Removing instance...",
"clearing_cache": "Clearing cache...",
"initialized": "Instance switcher initialized",
"connecting_state": "Establishing connection...",
"connected_state": "Successfully connected to instance",
"disconnected_state": "Disconnected from instance",
"stream_error": "Connection state monitoring failed",
"recent_instances_error": "Failed to load recent instances",
"instance_changed": "Switched to instance",
"current_instance_error": "Failed to track current instance",
"not_available": "Instance switching is not available",
"cleanup_completed": "Instance switcher cleanup completed"
},
"inspections": {
"description": "Inspect possible errors",

View file

@ -15,30 +15,21 @@
>
<div class="flex">
<tippy
v-if="kernelMode === 'desktop'"
v-if="platform.instance?.instanceSwitchingEnabled"
interactive
trigger="click"
theme="popover"
:on-shown="() => instanceSwitcherRef.focus()"
>
<div class="flex items-center cursor-pointer">
<div class="flex">
<span
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
>
{{ instanceDisplayName }}
{{
platform.instance.getCurrentInstance?.()?.displayName ||
"Hoppscotch"
}}
</span>
<span
v-if="
currentState.status === 'connected' &&
'type' in currentState.instance &&
currentState.instance.type === 'vendored'
"
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
>
{{ platform.instance.displayConfig.description }}
</span>
</div>
<IconChevronDown class="h-4 w-4 text-secondaryDark" />
</div>
<template #content="{ hide }">
@ -378,7 +369,6 @@ import {
BannerService,
} from "~/services/banner.service"
import { WorkspaceService } from "~/services/workspace.service"
import { InstanceSwitcherService } from "~/services/instance-switcher.service"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
@ -393,33 +383,14 @@ import { AdditionalLinksService } from "~/services/additionalLinks.service"
const t = useI18n()
const toast = useToast()
const kernelMode = getKernelMode()
const instanceSwitcherService =
kernelMode === "desktop" ? useService(InstanceSwitcherService) : null
const instanceSwitcherRef =
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
const downloadableLinksRef =
kernelMode === "web" ? ref<any | null>(null) : ref(null)
const instanceSwitcherRef =
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
const isUserAdmin = ref(false)
const currentState =
kernelMode === "desktop" && instanceSwitcherService
? useReadonlyStream(
instanceSwitcherService.getStateStream(),
instanceSwitcherService.getCurrentState().value
)
: ref({
status: "disconnected",
instance: { displayName: "Hoppscotch" },
})
const instanceDisplayName = computed(() => {
if (currentState.value.status !== "connected") {
return "Hoppscotch"
}
return currentState.value.instance.displayName
})
/**
* Feature flag to enable the workspace selector login conversion
*/

View file

@ -1,70 +1,54 @@
<template>
<div class="flex flex-col space-y-1 w-full">
<!-- Use custom component if platform provides one, otherwise fallback to default impl below -->
<component
:is="platform.instance.customInstanceSwitcherComponent"
v-if="platform.instance?.customInstanceSwitcherComponent"
@close-dropdown="$emit('close-dropdown')"
/>
<!-- Default impl -->
<div
class="flex items-center justify-between px-4 py-3 hover:accent-primaryLight rounded-md"
:class="{
'cursor-pointer': !isVendored,
'bg-accent text-accentContrast': isVendored,
}"
@click="!isVendored && connectToVendored()"
v-else-if="isInstanceSwitchingEnabled"
class="flex flex-col space-y-1 w-full"
>
<div
v-if="connectedInstance"
class="flex items-center justify-between px-4 py-3 bg-accent text-accentContrast rounded-md"
>
<div class="flex items-center gap-4">
<IconLucidePackage />
<IconLucideServer />
<div class="flex flex-col">
<span class="font-semibold uppercase">{{
platform.instance.displayConfig.displayName
connectedInstance.displayName
}}</span>
<div class="flex items-center gap-1">
<!-- NOTE:
If this is set to `platform.instance.displayConfig.description`
it'll be bound to app's perspective, i.e.
when in vendored cloud app, it'll show `Cloud`
and in vendored self-hosted app, it'll show `On-Prem`
even tho both are actually pointing to the same bundle.
Essentially switching instance is a **perspective shift**
for the underlying desktop app launcher.
The best way to solve this would be to make instance information
into "links" to the bundles hosted by the `appload` plugin,
which is already underway in HFE-829.
This is a workaround for the time being. See `Header.vue`
for code that maintains backwards compatibility.
-->
<span class="text-xs">Default</span>
<span class="text-xs"> app </span>
<span class="text-xs">{{ connectedInstance.kind }}</span>
<span
v-if="showVersionInfo && connectedInstance.version"
class="text-xs"
>
v{{ connectedInstance.version }}
</span>
</div>
</div>
</div>
<IconLucideCheck v-if="isVendored" />
<IconLucideCheck />
</div>
<div class="flex flex-col space-y-1">
<div
v-for="instance in recentInstances"
:key="instance.serverUrl"
class="flex items-center justify-between px-4 py-2 rounded-md group"
:class="{
'bg-accent text-accentContrast':
currentInstance &&
currentInstance.type === 'server' &&
currentInstance.serverUrl ===
instanceService.normalizeUrl(instance.serverUrl),
'hover:bg-primaryLight': !(
currentInstance &&
currentInstance.type === 'server' &&
currentInstance.serverUrl ===
instanceService.normalizeUrl(instance.serverUrl)
),
}"
>
<div
class="flex items-center gap-4 flex-1 cursor-pointer"
class="flex items-center justify-between px-4 py-2 rounded-md group hover:bg-primaryLight cursor-pointer"
@click="
!isConnectedTo(instance.serverUrl) &&
connectToServer(instance.serverUrl)
handleConnectToInstance(
instance.serverUrl,
instance.kind,
instance.displayName
)
"
>
<div class="flex items-center gap-4 flex-1">
<IconLucideServer />
<div class="flex flex-col">
<span
@ -73,13 +57,12 @@
theme: 'tooltip',
}"
class="font-semibold uppercase"
>{{ getHostname(instance.displayName) }}</span
>
{{ instance.displayName }}
</span>
<div class="flex items-center gap-1">
<span v-if="isOnPrem(instance.serverUrl)" class="text-xs"
>On-prem</span
>
<span v-if="instance.version" class="text-xs">
<span class="text-xs">{{ instance.kind }}</span>
<span v-if="showVersionInfo && instance.version" class="text-xs">
v{{ instance.version }}
</span>
</div>
@ -87,26 +70,29 @@
</div>
<div class="flex items-center">
<div class="w-8 flex justify-center">
<IconLucideCheck
v-if="isConnectedTo(instance.serverUrl)"
class="text-current"
/>
<HoppButtonSecondary
v-if="!isConnectedTo(instance.serverUrl)"
v-if="allowInstanceRemoval && instance.kind !== 'vendored'"
v-tippy="{
content: t('action.remove_instance') || 'Remove instance',
theme: 'tooltip',
}"
class="!p-0 ml-4 opacity-0 group-hover:opacity-100 transition-opacity"
:icon="IconLucideTrash"
@click.stop="
confirmRemove(instance.serverUrl, instance.displayName)
"
@click.stop="confirmRemove(instance)"
/>
<IconLucideLock
v-else-if="instance.kind === 'vendored'"
v-tippy="{
content: 'Built-in instance cannot be removed',
theme: 'tooltip',
}"
class="!p-0 ml-4 opacity-50 text-secondaryLight"
/>
</div>
</div>
</div>
</div>
<hr />
<HoppButtonSecondary
@ -114,12 +100,7 @@
:icon="IconLucidePlus"
filled
outline
@click="
() => {
showAddModal = true
$emit('close-dropdown')
}
"
@click="openAddModal"
/>
<HoppSmartModal
@ -127,7 +108,7 @@
dialog
:title="t('instances.add_new') || 'Add New Instance'"
styles="sm:max-w-md"
@close="showAddModal = false"
@close="closeAddModal"
>
<template #body>
<form class="flex flex-col space-y-4" @submit.prevent="handleConnect">
@ -146,7 +127,6 @@
<template #prefix>
<IconLucideGlobe />
</template>
<HoppSmartInput>
<template #suffix>
<IconLucideCheck
v-if="
@ -159,14 +139,11 @@
class="text-green-500"
/>
<IconLucideAlertCircle
v-else-if="
!isConnecting && !connectionError && isCurrentUrl
"
v-else-if="!isConnecting && !connectionError && isCurrentUrl"
class="text-amber-500"
/>
</template>
</HoppSmartInput>
</HoppSmartInput>
<span v-if="connectionError" class="text-red-500 text-tiny">
{{ connectionError }}
</span>
@ -189,7 +166,7 @@
</template>
<template #footer>
<div class="flex justify-end w-full">
<div v-if="allowCacheClear" class="flex justify-end w-full">
<HoppButtonSecondary
v-tippy="{
content: t('instances.clear_cached_bundles'),
@ -205,12 +182,13 @@
</div>
</template>
</HoppSmartModal>
<HoppSmartModal
v-if="showRemoveModal"
dialog
:title="t('instances.confirm_remove') || 'Confirm Removal'"
styles="sm:max-w-md"
@close="showRemoveModal = false"
@close="closeRemoveModal"
>
<template #body>
<p>
@ -218,10 +196,7 @@
t("instances.remove_warning") ||
"Are you sure you want to remove this instance?"
}}
<span class="font-bold">
{{ confirmedRemoveDisplayName }}
</span>
<span class="font-bold">{{ instanceToRemove?.displayName }}</span>
</p>
</template>
<template #footer>
@ -230,30 +205,40 @@
:label="t('action.cancel') || 'Cancel'"
outline
filled
@click="showRemoveModal = false"
@click="closeRemoveModal"
/>
<HoppButtonPrimary
:label="t('action.remove') || 'Remove'"
filled
outline
@click="removeInstance(confirmedRemoveUrl)"
@click="handleRemoveInstance"
/>
</div>
</template>
</HoppSmartModal>
</div>
<!-- Fallback when instance switching is disabled -->
<div v-else class="flex items-center justify-center px-4 py-3">
<span class="text-secondaryLight text-sm"
>Instance switching not available</span
>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import { useService } from "dioc/vue"
import { ref, computed, watch, onMounted, onUnmounted } from "vue"
import { Subscription } from "rxjs"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import {
InstanceSwitcherService,
InstanceType,
} from "~/services/instance-switcher.service"
import { useToast } from "@composables/toast"
import { platform } from "~/platform"
import type {
ConnectionState,
Instance,
InstanceKind,
} from "~/platform/instance"
import IconLucideGlobe from "~icons/lucide/globe"
import IconLucideCheck from "~icons/lucide/check"
@ -262,60 +247,62 @@ import IconLucideTrash from "~icons/lucide/trash"
import IconLucideTrash2 from "~icons/lucide/trash-2"
import IconLucideAlertCircle from "~icons/lucide/alert-circle"
import IconLucidePlus from "~icons/lucide/plus"
import IconLucidePackage from "~icons/lucide/package"
const t = useI18n()
const instanceService = useService(InstanceSwitcherService)
const toast = useToast()
const emit = defineEmits(["close-dropdown"])
const emit = defineEmits<{
"close-dropdown": []
}>()
const showVersionInfo = ref(true)
const allowInstanceRemoval = ref(true)
const allowCacheClear = ref(true)
const showAddModal = ref(false)
const newInstanceUrl = ref("")
const isClearingCache = ref(false)
const showRemoveModal = ref(false)
const confirmedRemoveUrl = ref("")
const confirmedRemoveDisplayName = ref("")
const confirmRemove = (url: string, displayName: string) => {
confirmedRemoveUrl.value = url
confirmedRemoveDisplayName.value = displayName || getHostname(url)
showRemoveModal.value = true
emit("close-dropdown")
}
const newInstanceUrl = ref("")
const isConnecting = ref(false)
const connectionError = ref("")
const isClearingCache = ref(false)
const state = useReadonlyStream(
instanceService.getStateStream(),
instanceService.getCurrentState().value
)
const instanceToRemove = ref<Instance | null>(null)
const recentInstances = useReadonlyStream(
instanceService.getRecentInstancesStream(),
[]
)
const connectionState = ref<ConnectionState>({ status: "idle" })
const recentInstancesList = ref<Instance[]>([])
const currentInstance = ref<Instance | null>(null)
const currentInstance = computed<InstanceType | null>(() => {
return state.value.status === "connected" ? state.value.instance : null
let connectionStateSubscription: Subscription | null = null
let recentInstancesSubscription: Subscription | null = null
let currentInstanceSubscription: Subscription | null = null
const isInstanceSwitchingEnabled = computed(() => {
return platform.instance?.instanceSwitchingEnabled ?? false
})
const isConnecting = computed(() => state.value.status === "connecting")
const connectionError = computed(() => {
return state.value.status === "error" ? state.value.message : null
const connectedInstance = computed(() => {
return isConnectedState(connectionState.value) ? currentInstance.value : null
})
const isVendored = computed(() => {
return currentInstance.value?.type === platform.instance.instanceType
const recentInstances = computed(() => {
return recentInstancesList.value.filter(
(instance) => instance.serverUrl !== currentInstance.value?.serverUrl
)
})
const isValidUrl = computed(() => {
if (!newInstanceUrl.value) return false
if (platform.instance?.normalizeUrl) {
return platform.instance.normalizeUrl(newInstanceUrl.value) !== null
}
try {
const normalizedUrl = newInstanceUrl.value.startsWith("http")
const urlToTest = newInstanceUrl.value.startsWith("http")
? newInstanceUrl.value
: `http://${newInstanceUrl.value}`
const url = new URL(normalizedUrl)
console.info("url", url)
: `https://${newInstanceUrl.value}`
new URL(urlToTest)
return true
} catch {
return false
@ -323,77 +310,492 @@ const isValidUrl = computed(() => {
})
const isCurrentUrl = computed(() => {
if (!newInstanceUrl.value) return false
if (currentInstance.value?.type !== "server") return false
if (!newInstanceUrl.value || !currentInstance.value) return false
try {
return instanceService.isCurrentlyConnectedTo(newInstanceUrl.value)
} catch {
return false
}
const normalizedNew =
platform.instance?.normalizeUrl?.(newInstanceUrl.value) ||
newInstanceUrl.value
const normalizedCurrent =
platform.instance?.normalizeUrl?.(currentInstance.value.serverUrl) ||
currentInstance.value.serverUrl
return normalizedNew === normalizedCurrent
})
const isConnectedTo = (url: string): boolean => {
return instanceService.isCurrentlyConnectedTo(url)
const isConnectedState = (
state: ConnectionState
): state is Extract<ConnectionState, { status: "connected" }> => {
return state.status === "connected"
}
const getHostname = (url: string): string => {
try {
if (!url.startsWith("http")) {
return url.toUpperCase()
}
const hostname = new URL(url).hostname
return hostname.toUpperCase()
} catch {
return url.toUpperCase()
}
const isErrorState = (
state: ConnectionState
): state is Extract<ConnectionState, { status: "error" }> => {
return state.status === "error"
}
const isOnPrem = (url: string): boolean => {
const openAddModal = () => {
showAddModal.value = true
emit("close-dropdown")
// NOTE: Just for debugging
// toast.info(t("instances.opening_add_modal") || "Opening add instance dialog")
}
const closeAddModal = () => {
showAddModal.value = false
newInstanceUrl.value = ""
connectionError.value = ""
// NOTE: Just for debugging
// toast.info(t("instances.closed_add_modal") || "Add instance dialog closed")
}
const closeRemoveModal = () => {
showRemoveModal.value = false
instanceToRemove.value = null
// NOTE: Just for debugging
// toast.info(t("instances.cancelled_removal") || "Instance removal cancelled")
}
const validateConnectionSupport = (): boolean => {
if (!platform.instance?.connectToInstance) {
toast.error("Instance connection not supported")
return false
}
return true
}
const executeBeforeConnectHook = async (
serverUrl: string,
instanceKind: InstanceKind,
displayName?: string
): Promise<boolean> => {
if (!platform.instance?.beforeConnect) return true
try {
const hostname = new URL(url.startsWith("http") ? url : `http://${url}`)
.hostname
return hostname !== "hoppscotch.com"
} catch {
const result = await platform.instance.beforeConnect(
serverUrl,
instanceKind,
displayName
)
if (!result) {
toast.info(
t("instances.connection_cancelled") ||
"Connection cancelled by pre-connect validation"
)
}
return result
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Pre-connect validation failed"
toast.error(errorMessage)
return false
}
}
const connectToVendored = async () => {
if (isVendored.value) return
await instanceService.connectToVendoredInstance()
if (showAddModal.value) showAddModal.value = false
emit("close-dropdown")
const executeAfterConnectHook = async (): Promise<void> => {
if (platform.instance?.afterConnect && currentInstance.value) {
try {
await platform.instance.afterConnect(currentInstance.value)
toast.success(
t("instances.post_connect_completed") ||
"Post-connection setup completed"
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Post-connection setup failed"
toast.info(errorMessage)
}
}
}
const connectToServer = async (url: string) => {
await instanceService.connectToServerInstance(url)
const handleConnectionSuccess = async (message: string): Promise<void> => {
toast.success(message || "Connected successfully")
emit("close-dropdown")
await executeAfterConnectHook()
}
const removeInstance = async (url: string) => {
await instanceService.removeInstance(url)
showRemoveModal.value = false
const handleConnectionError = (message: string, serverUrl: string): void => {
connectionError.value = message || "Connection failed"
toast.error(message || "Connection failed")
if (platform.instance?.onConnectionError) {
platform.instance.onConnectionError(message, serverUrl)
}
}
const performConnection = async (
serverUrl: string,
instanceKind: InstanceKind,
displayName?: string
): Promise<void> => {
if (!platform.instance?.connectToInstance) return
toast.info(
t("instances.connecting") || `Connecting to ${displayName || serverUrl}...`
)
const result = await platform.instance.connectToInstance(
serverUrl,
instanceKind,
displayName
)
if (result.success) {
await handleConnectionSuccess(result.message)
} else {
handleConnectionError(result.message, serverUrl)
}
}
const handleConnectToInstance = async (
serverUrl: string,
instanceKind: InstanceKind = "on-prem",
displayName?: string
) => {
if (!validateConnectionSupport()) return
isConnecting.value = true
connectionError.value = ""
try {
const shouldConnect = await executeBeforeConnectHook(
serverUrl,
instanceKind,
displayName
)
if (!shouldConnect) return
await performConnection(serverUrl, instanceKind, displayName)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred"
handleConnectionError(errorMessage, serverUrl)
} finally {
isConnecting.value = false
}
}
const handleConnect = async () => {
if (!newInstanceUrl.value || !isValidUrl.value || isCurrentUrl.value) return
const success = await instanceService.connectToServerInstance(
const instanceKind: InstanceKind = "on-prem"
await handleConnectToInstance(
newInstanceUrl.value,
instanceKind,
newInstanceUrl.value
)
if (success) {
newInstanceUrl.value = ""
showAddModal.value = false
if (!connectionError.value) {
closeAddModal()
}
}
const confirmRemove = (instance: Instance) => {
instanceToRemove.value = instance
showRemoveModal.value = true
toast.info(
t("instances.confirm_removal") ||
`Confirm removal of ${instance.displayName}`
)
}
const validateRemovalSupport = (): boolean => {
if (!platform.instance?.removeInstance) {
toast.error("Instance removal not supported")
return false
}
return true
}
const executeBeforeRemoveHook = async (
instance: Instance
): Promise<boolean> => {
if (!platform.instance?.beforeRemove) return true
try {
const result = await platform.instance.beforeRemove(instance)
if (!result) {
toast.info(
t("instances.removal_cancelled") ||
"Instance removal cancelled by pre-removal validation"
)
}
return result
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Pre-removal validation failed"
toast.error(errorMessage)
return false
}
}
const executeAfterRemoveHook = async (instance: Instance): Promise<void> => {
if (platform.instance?.afterRemove) {
try {
await platform.instance.afterRemove(instance)
toast.success(
t("instances.post_remove_completed") || "Post-removal cleanup completed"
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Post-removal cleanup failed"
toast.info(errorMessage)
}
}
}
const handleRemovalSuccess = async (
message: string,
instance: Instance
): Promise<void> => {
toast.success(message || "Instance removed successfully")
await executeAfterRemoveHook(instance)
}
const handleRemovalError = (message: string, instance: Instance): void => {
toast.error(message || "Failed to remove instance")
if (platform.instance?.onRemoveError) {
platform.instance.onRemoveError(message, instance)
}
}
const performRemoval = async (instance: Instance): Promise<void> => {
if (!platform.instance?.removeInstance) return
toast.info(t("instances.removing") || `Removing ${instance.displayName}...`)
const result = await platform.instance.removeInstance(instance)
if (result.success) {
await handleRemovalSuccess(result.message, instance)
} else {
handleRemovalError(result.message, instance)
}
}
const handleRemoveInstance = async () => {
if (!instanceToRemove.value || !validateRemovalSupport()) return
const instance = instanceToRemove.value
try {
const shouldRemove = await executeBeforeRemoveHook(instance)
if (!shouldRemove) {
closeRemoveModal()
return
}
await performRemoval(instance)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred"
handleRemovalError(errorMessage, instance)
} finally {
closeRemoveModal()
}
}
const validateCacheClearSupport = (): boolean => {
if (!platform.instance?.clearCache) {
toast.error("Cache clearing not supported")
return false
}
return true
}
const performCacheClear = async (): Promise<void> => {
if (!platform.instance?.clearCache) return
toast.info(t("instances.clearing_cache") || "Clearing cache...")
const result = await platform.instance.clearCache()
if (result.success) {
toast.success(result.message || "Cache cleared successfully")
} else {
toast.error(result.message || "Failed to clear cache")
}
}
const handleClearCache = async () => {
if (isClearingCache.value) return
if (!validateCacheClearSupport()) return
isClearingCache.value = true
await instanceService.clearCache()
try {
await performCacheClear()
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred"
toast.error(errorMessage)
} finally {
isClearingCache.value = false
}
}
const initializeSynchronousState = (): void => {
if (!platform.instance) return
if (platform.instance.getCurrentConnectionState) {
connectionState.value = platform.instance.getCurrentConnectionState()
}
if (platform.instance.getRecentInstances) {
recentInstancesList.value = platform.instance.getRecentInstances()
}
if (platform.instance.getCurrentInstance) {
currentInstance.value = platform.instance.getCurrentInstance()
}
// NOTE: Just for debugging
// toast.info(t("instances.initialized") || "Instance switcher initialized")
}
const handleConnectionStateChange = (state: ConnectionState): void => {
const previousState = connectionState.value.status
connectionState.value = state
if (isErrorState(state)) {
connectionError.value = state.message
if (previousState !== "error") {
toast.error(state.message || "Connection error occurred")
}
} else if (state.status === "connecting") {
connectionError.value = ""
isConnecting.value = true
if (previousState !== "connecting") {
toast.info(
t("instances.connecting_state") || "Establishing connection..."
)
}
} else if (state.status === "connected") {
isConnecting.value = false
if (previousState !== "connected") {
toast.success(
t("instances.connected_state") || "Successfully connected to instance"
)
}
} else if (state.status === "idle") {
isConnecting.value = false
if (previousState === "connected") {
toast.info(
t("instances.disconnected_state") || "Disconnected from instance"
)
}
}
}
const subscribeToConnectionState = (): void => {
if (!platform.instance?.getConnectionStateStream) return
connectionStateSubscription = platform.instance
.getConnectionStateStream()
.subscribe({
next: handleConnectionStateChange,
error: (error) => {
console.error("Connection state stream error:", error)
toast.error(
t("instances.stream_error") || "Connection state monitoring failed"
)
connectionState.value = {
status: "error",
target: "stream",
message: error.message,
}
},
})
}
const subscribeToRecentInstances = (): void => {
if (!platform.instance?.getRecentInstancesStream) return
recentInstancesSubscription = platform.instance
.getRecentInstancesStream()
.subscribe({
next: (instances) => {
recentInstancesList.value = instances
},
error: (error) => {
console.error("Recent instances stream error:", error)
toast.error(
t("instances.recent_instances_error") ||
"Failed to load recent instances"
)
},
})
}
const subscribeToCurrentInstance = (): void => {
if (!platform.instance?.getCurrentInstanceStream) return
currentInstanceSubscription = platform.instance
.getCurrentInstanceStream()
.subscribe({
next: (instance) => {
const previousInstance = currentInstance.value
currentInstance.value = instance
if (
instance &&
(!previousInstance ||
previousInstance.serverUrl !== instance.serverUrl)
) {
toast.success(
t("instances.instance_changed") ||
`Switched to ${instance.displayName}`
)
}
},
error: (error) => {
console.error("Current instance stream error:", error)
toast.error(
t("instances.current_instance_error") ||
"Failed to track current instance"
)
},
})
}
const initializeStreams = () => {
if (!platform.instance) {
toast.info(
t("instances.not_available") || "Instance switching is not available"
)
return
}
initializeSynchronousState()
subscribeToConnectionState()
subscribeToRecentInstances()
subscribeToCurrentInstance()
}
const cleanup = () => {
connectionStateSubscription?.unsubscribe()
recentInstancesSubscription?.unsubscribe()
currentInstanceSubscription?.unsubscribe()
toast.info(
t("instances.cleanup_completed") || "Instance switcher cleanup completed"
)
}
watch(newInstanceUrl, () => {
connectionError.value = ""
})
onMounted(() => {
initializeStreams()
})
onUnmounted(() => {
cleanup()
})
</script>

View file

@ -6,9 +6,41 @@ import type {
} from "@hoppscotch/kernel"
import * as E from "fp-ts/Either"
import { getModule } from "."
import { invoke } from "@tauri-apps/api/core"
import { join } from "@tauri-apps/api/path"
const STORE_PATH = `${window.location.host}.hoppscotch.store`
export const getConfigDir = async (): Promise<string> => {
return await invoke<string>("get_config_dir")
}
export const getBackupDir = async (): Promise<string> => {
return await invoke<string>("get_backup_dir")
}
export const getLatestDir = async (): Promise<string> => {
return await invoke<string>("get_latest_dir")
}
export const getStoreDir = async (): Promise<string> => {
return await invoke<string>("get_store_dir")
}
export const getInstanceDir = async (): Promise<string> => {
return await invoke<string>("get_instance_dir")
}
const getStorePath = async (): Promise<string> => {
try {
const storeDir = await getStoreDir()
return join(storeDir, STORE_PATH)
} catch (error) {
console.error("Failed to get store directory:", error)
return "hoppscotch-unified.store"
}
}
export const Store = (() => {
const module = () => getModule("store")
@ -16,7 +48,8 @@ export const Store = (() => {
capabilities: () => module().capabilities,
init: async () => {
return module().init(STORE_PATH)
const storePath = await getStorePath()
return module().init(storePath)
},
set: async (
@ -25,49 +58,57 @@ export const Store = (() => {
value: unknown,
options?: StorageOptions
): Promise<E.Either<StoreError, void>> => {
return module().set(STORE_PATH, namespace, key, value, options)
const storePath = await getStorePath()
return module().set(storePath, namespace, key, value, options)
},
get: async <T>(
namespace: string,
key: string
): Promise<E.Either<StoreError, T | undefined>> => {
return module().get<T>(STORE_PATH, namespace, key)
const storePath = await getStorePath()
return module().get<T>(storePath, namespace, key)
},
remove: async (
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> => {
return module().remove(STORE_PATH, namespace, key)
const storePath = await getStorePath()
return module().remove(storePath, namespace, key)
},
clear: async (namespace?: string): Promise<E.Either<StoreError, void>> => {
return module().clear(STORE_PATH, namespace)
const storePath = await getStorePath()
return module().clear(storePath, namespace)
},
has: async (
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> => {
return module().has(STORE_PATH, namespace, key)
const storePath = await getStorePath()
return module().has(storePath, namespace, key)
},
listNamespaces: async (): Promise<E.Either<StoreError, string[]>> => {
return module().listNamespaces(STORE_PATH)
const storePath = await getStorePath()
return module().listNamespaces(storePath)
},
listKeys: async (
namespace: string
): Promise<E.Either<StoreError, string[]>> => {
return module().listKeys(STORE_PATH, namespace)
const storePath = await getStorePath()
return module().listKeys(storePath, namespace)
},
watch: async (
namespace: string,
key: string
): Promise<StoreEventEmitter<StoreEvents>> => {
return module().watch(STORE_PATH, namespace, key)
const storePath = await getStorePath()
return module().watch(storePath, namespace, key)
},
} as const
})()

View file

@ -42,6 +42,7 @@ export type PlatformDef = {
// NOTE: To be deprecated
// interceptors: InterceptorsPlatformDef
kernelInterceptors: KernelInterceptorsPlatformDef
instance?: InstancePlatformDef
additionalInspectors?: InspectorsPlatformDef
spotlight?: SpotlightPlatformDef
platformFeatureFlags: {

View file

@ -1,10 +1,222 @@
export type InstancePlatformDef = {
instanceType: "vendored"
displayConfig: {
import { Observable } from "rxjs"
import { Component } from "vue"
import { type LoadOptions } from "@hoppscotch/plugin-appload"
export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored"
export type Instance = {
kind: InstanceKind
serverUrl: string
displayName: string
description: string
version: string
connectingMessage: string
connectedMessage: string
lastUsed: string
bundleName?: string
}
export const VENDORED_INSTANCE_CONFIG: Instance = {
kind: "vendored" as const,
serverUrl: "app://hoppscotch",
displayName: "Hoppscotch Desktop",
version: "25.9.0",
lastUsed: new Date().toISOString(),
bundleName: "Hoppscotch",
}
export type ConnectionState =
| { status: "idle" }
| { status: "connecting"; target: string }
| { status: "connected"; instance: Instance }
| { status: "error"; target: string; message: string }
export type OperationResult = {
success: boolean
message: string
data?: any
}
export type InstancePlatformDef = {
/**
* Whether instance switching is enabled for this platform
*/
instanceSwitchingEnabled: boolean
/**
* Custom instance switcher component to replace the default UI
*/
customInstanceSwitcherComponent?: Component
/**
* Returns an observable stream of the current connection state
*/
getConnectionStateStream?: () => Observable<ConnectionState>
/**
* Returns an observable stream of recent instances
*/
getRecentInstancesStream?: () => Observable<Instance[]>
/**
* Returns an observable stream of the current active instance
*/
getCurrentInstanceStream?: () => Observable<Instance | null>
/**
* Gets the current connection state synchronously
*/
getCurrentConnectionState?: () => ConnectionState
/**
* Gets the list of recent instances synchronously
*/
getRecentInstances?: () => Instance[]
/**
* Gets the current active instance synchronously
*/
getCurrentInstance?: () => Instance | null
/**
* Connects to an instance with the given options
*/
connectToInstance?: (
serverUrl: string,
instanceKind: InstanceKind,
displayName?: string,
options?: Partial<LoadOptions>
) => Promise<OperationResult>
/**
* Downloads an instance bundle without connecting
*/
downloadInstance?: (
serverUrl: string,
instanceKind: InstanceKind,
displayName?: string
) => Promise<OperationResult>
/**
* Loads a previously downloaded instance
*/
loadInstance?: (
instance: Instance,
options?: Partial<LoadOptions>
) => Promise<OperationResult>
/**
* Removes an instance and its associated data
*/
removeInstance?: (instance: Instance) => Promise<OperationResult>
/**
* Disconnects from the current instance
*/
disconnect?: () => Promise<OperationResult>
/**
* Clears all cached instances and data
*/
clearCache?: () => Promise<OperationResult>
/**
* Validates and normalizes a server URL
*/
normalizeUrl?: (url: string) => string | null
/**
* Hook called before connecting to an instance
* Return false to prevent the connection
*/
beforeConnect?: (
serverUrl: string,
instanceKind: InstanceKind,
displayName?: string
) => Promise<boolean>
/**
* Hook called after successful connection
*/
afterConnect?: (instance: Instance) => Promise<void>
/**
* Hook called before disconnecting from an instance
* Return false to prevent the disconnection
*/
beforeDisconnect?: (instance: Instance) => Promise<boolean>
/**
* Hook called after successful disconnection
*/
afterDisconnect?: () => Promise<void>
/**
* Hook called before removing an instance
* Return false to prevent the removal
*/
beforeRemove?: (instance: Instance) => Promise<boolean>
/**
* Hook called after successful instance removal
*/
afterRemove?: (instance: Instance) => Promise<void>
/**
* Custom error handling for connection failures
*/
onConnectionError?: (error: string, target: string) => void
/**
* Custom error handling for download failures
*/
onDownloadError?: (error: string, serverUrl: string) => void
/**
* Custom error handling for load failures
*/
onLoadError?: (error: string, instance: Instance) => void
/**
* Custom error handling for removal failures
*/
onRemoveError?: (error: string, instance: Instance) => void
/**
* Validates if a server URL is reachable and compatible
*/
validateServerUrl?: (url: string) => Promise<{
valid: boolean
version?: string
instanceKind?: InstanceKind
error?: string
}>
/**
* Configuration options for instance management
*/
config?: {
/**
* Maximum number of recent instances to keep
*/
maxRecentInstances?: number
/**
* Default window options for loaded instances
*/
defaultWindowOptions?: LoadOptions["window"]
/**
* Whether to automatically reconnect to the last instance on startup
*/
autoReconnect?: boolean
/**
* Timeout for connection attempts in milliseconds
*/
connectionTimeout?: number
/**
* Whether to show confirmation dialogs for destructive operations
*/
confirmDestructiveOperations?: boolean
}
}

View file

@ -1,508 +0,0 @@
import { Service } from "dioc"
import { BehaviorSubject, Observable } from "rxjs"
import { computed } from "vue"
import { LazyStore } from "@tauri-apps/plugin-store"
import {
getCurrentWebviewWindow,
getAllWebviewWindows,
} from "@tauri-apps/api/webviewWindow"
import {
download,
load,
clear,
remove,
close,
} from "@hoppscotch/plugin-appload"
import { useToast } from "~/composables/toast"
import { platform } from "~/platform"
const STORE_PATH = "hopp.store.json"
const MAX_RECENT_INSTANCES = 10
type ServerInstance = {
type: "server"
serverUrl: string
displayName: string
version: string
lastUsed: string
bundleName?: string
}
type VendoredInstance = {
type: "vendored"
displayName: string
version: string
}
export type InstanceType = ServerInstance | VendoredInstance
export type ConnectionState =
| { status: "idle" }
| { status: "connecting"; target: string }
| { status: "connected"; instance: InstanceType }
| { status: "error"; target: string; message: string }
export class InstanceSwitcherService extends Service<ConnectionState> {
public static readonly ID = "INSTANCE_SWITCHER_SERVICE"
private state$ = new BehaviorSubject<ConnectionState>({ status: "idle" })
private recentInstances$ = new BehaviorSubject<ServerInstance[]>([])
private store!: LazyStore
private toast = useToast()
public getVendoredInstance(): VendoredInstance {
const { instanceType, displayConfig } = platform.instance
const { displayName, version } = displayConfig
return {
type: instanceType,
displayName,
version,
}
}
override async onServiceInit(): Promise<void> {
this.store = new LazyStore(STORE_PATH)
await this.store.init()
await this.loadRecentInstances()
if (this.inVendoredEnvironment()) {
this.state$.next({
status: "connected",
instance: this.getVendoredInstance(),
})
this.emit(this.state$.value)
} else {
await this.loadSavedState()
}
}
private inVendoredEnvironment(): boolean {
try {
return (
window.location.hostname === "hoppscotch" &&
window.location.protocol === "app:"
)
} catch {
return false
}
}
public getStateStream(): Observable<ConnectionState> {
return this.state$
}
public getRecentInstancesStream(): Observable<ServerInstance[]> {
return this.recentInstances$
}
public getCurrentState() {
return computed(() => this.state$.value)
}
public getCurrentInstance() {
return computed(() => {
const state = this.state$.value
return state.status === "connected" ? state.instance : null
})
}
public getRecentInstances() {
return computed(() => this.recentInstances$.value)
}
public isConnecting() {
return computed(() => this.state$.value.status === "connecting")
}
public getConnectionError() {
return computed(() => {
const state = this.state$.value
return state.status === "error" ? state.message : null
})
}
public async connectToVendoredInstance(): Promise<boolean> {
if (this.isCurrentlyVendored()) {
return true
}
this.state$.next({
status: "connecting",
target: this.getVendoredInstance().displayName,
})
this.emit(this.state$.value)
try {
this.state$.next({
status: "connected",
instance: this.getVendoredInstance(),
})
this.emit(this.state$.value)
await this.saveCurrentState()
this.toast.success(
platform.instance.displayConfig.connectingMessage.replace(
"{instanceName}",
this.getVendoredInstance().displayName
)
)
const loadResponse = await load({
bundleName: "Hoppscotch",
window: { title: "Hoppscotch" },
})
if (!loadResponse.success) {
throw new Error(
`Failed to load ${this.getVendoredInstance().type} bundle`
)
}
this.toast.success(
platform.instance.displayConfig.connectedMessage.replace(
"{instanceName}",
this.getVendoredInstance().displayName
)
)
// Close current window AFTER successful load
// NOTE: No need to await it.
this.closeCurrentWindow()
return true
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
this.state$.next({
status: "error",
target: this.getVendoredInstance().displayName,
message: errorMessage,
})
this.emit(this.state$.value)
this.toast.error(`Failed to connect: ${errorMessage}`)
return false
}
}
public async connectToServerInstance(serverUrl: string): Promise<boolean> {
if (this.isCurrentlyConnectedTo(serverUrl)) {
const currentState = this.state$.value
if (
currentState.status === "connected" &&
currentState.instance.type === "server"
) {
const updatedInstance: ServerInstance = {
...currentState.instance,
lastUsed: new Date().toISOString(),
}
await this.updateRecentInstance(updatedInstance)
}
return true
}
const normalizedUrl = this.normalizeUrl(serverUrl)
const displayName = this.getDisplayNameFromUrl(normalizedUrl)
this.state$.next({
status: "connecting",
target: displayName,
})
this.emit(this.state$.value)
try {
const downloadResponse = await download({ serverUrl: normalizedUrl })
if (!downloadResponse.success) {
throw new Error("Failed to download bundle")
}
const instance: ServerInstance = {
type: "server",
serverUrl: normalizedUrl,
displayName,
version: downloadResponse.version,
lastUsed: new Date().toISOString(),
bundleName: downloadResponse.bundleName,
}
await this.updateRecentInstance(instance)
this.state$.next({
status: "connected",
instance,
})
this.emit(this.state$.value)
await this.saveCurrentState()
this.toast.success(`Connecting to ${displayName}`)
const loadResponse = await load({
bundleName: downloadResponse.bundleName,
window: { title: "Hoppscotch" },
})
if (!loadResponse.success) {
throw new Error("Failed to load bundle")
}
// Close current window AFTER successful load
// NOTE: No need to await it.
this.closeCurrentWindow()
this.toast.success(`Connected to ${displayName}`)
return true
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
this.state$.next({
status: "error",
target: displayName,
message: errorMessage,
})
this.emit(this.state$.value)
this.toast.error(`Connection failed: ${errorMessage}`)
return false
}
}
/**
* Closes the current window using Tauri's window management
*/
private async closeCurrentWindow(): Promise<void> {
try {
const currentWindow = getCurrentWebviewWindow()
const currentLabel = currentWindow.label
// Don't close if we're the main window or if there are no other windows
if (currentLabel === "main") {
const allWindows = await getAllWebviewWindows()
if (allWindows.length <= 1) {
// Don't close the last window
return
}
}
const closeResponse = await close({ windowLabel: currentLabel })
if (!closeResponse.success) {
console.warn(`Failed to close window ${currentLabel}`)
}
} catch (error) {
console.warn("Failed to close current window:", error)
// Don't throw - window closing shouldn't block the operation
}
}
public async removeInstance(serverUrl: string): Promise<boolean> {
try {
const normalizedUrl = this.normalizeUrl(serverUrl)
const instanceToRemove = this.recentInstances$.value.find(
(instance) => instance.serverUrl === normalizedUrl
)
if (!instanceToRemove) {
return false
}
if (instanceToRemove.bundleName) {
try {
await remove({
bundleName: instanceToRemove.bundleName,
serverUrl: normalizedUrl,
})
} catch (error) {
console.error("Failed to remove bundle from storage:", error)
// Continue with instance removal even if bundle removal fails
}
}
const instances = this.recentInstances$.value.filter(
(instance) => instance.serverUrl !== normalizedUrl
)
this.recentInstances$.next(instances)
await this.saveRecentInstances()
const displayName = this.getDisplayNameFromUrl(serverUrl)
this.toast.success(`Removed ${displayName}`)
// If we're currently connected to this instance, close the window and go to idle state
if (this.isCurrentlyConnectedTo(serverUrl)) {
await this.closeCurrentWindow()
this.state$.next({ status: "idle" })
this.emit(this.state$.value)
await this.saveCurrentState()
}
return true
} catch (error) {
this.toast.error("Failed to remove instance")
return false
}
}
public async clearCache(): Promise<boolean> {
try {
await this.closeCurrentWindow()
await clear()
this.toast.success("Cache cleared successfully")
return true
} catch (error) {
this.toast.error("Failed to clear cache")
return false
}
}
public getCurrentInstanceDisplayName(): string {
const state = this.state$.value
if (state.status !== "connected") {
return "Hoppscotch"
}
return state.instance.displayName
}
public getDisplayNameFromUrl(url: string): string {
try {
const urlObj = new URL(url)
// We don't want entire hostname, only the specific org
const hostnameParts = urlObj.hostname.split(".")
const mainDomain = hostnameParts.slice(-2).join(".")
if (mainDomain === "hoppscotch") {
return "Hoppscotch"
}
return mainDomain || urlObj.hostname.replace(/^www\./, "")
} catch {
return url
}
}
public isCurrentlyVendored(): boolean {
const state = this.state$.value
return (
state.status === "connected" &&
state.instance.type === this.getVendoredInstance().type
)
}
public isCurrentlyConnectedTo(serverUrl: string): boolean {
const state = this.state$.value
if (state.status !== "connected") return false
const instance = state.instance
if (instance.type !== "server") return false
return instance.serverUrl === this.normalizeUrl(serverUrl)
}
public normalizeUrl(url: string): string {
try {
const withProtocol = url.startsWith("http") ? url : `http://${url}`
const urlObj = new URL(withProtocol)
const pathSegments = urlObj.pathname.split("/").filter(Boolean)
if (!pathSegments.includes("desktop-app-server")) {
// We try `desktop-app-server` subpath first
const withSubpath = new URL(withProtocol)
withSubpath.pathname = `/desktop-app-server${urlObj.pathname}`
// If it fails, fall back to the original URL with default port
try {
return withSubpath.toString().replace(/\/$/, "")
} catch {
if (!urlObj.port) {
urlObj.port = "3200"
}
return urlObj.toString().replace(/\/$/, "")
}
}
return urlObj.toString().replace(/\/$/, "")
} catch (error) {
return url
}
}
private async loadSavedState(): Promise<void> {
try {
const savedState =
await this.store.get<ConnectionState>("connectionState")
if (savedState && savedState.status === "connected") {
if (savedState.instance.type === "server") {
this.state$.next(savedState)
this.emit(this.state$.value)
}
}
} catch (error) {
console.error("Failed to load saved state:", error)
this.state$.next({ status: "idle" })
}
}
private async saveCurrentState(): Promise<void> {
try {
await this.store.set("connectionState", this.state$.value)
await this.store.save()
} catch (error) {
console.error("Failed to save current state:", error)
}
}
private async loadRecentInstances(): Promise<void> {
try {
const instances =
(await this.store.get<ServerInstance[]>("recentInstances")) || []
const sortedInstances = instances.sort(
(a, b) =>
new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
)
this.recentInstances$.next(sortedInstances)
} catch (error) {
console.error("Failed to load recent instances:", error)
this.recentInstances$.next([])
}
}
private async saveRecentInstances(): Promise<void> {
try {
await this.store.set("recentInstances", this.recentInstances$.value)
await this.store.save()
} catch (error) {
console.error("Failed to save recent instances:", error)
}
}
private async updateRecentInstance(instance: ServerInstance): Promise<void> {
try {
const currentInstances = [...this.recentInstances$.value]
const instances = currentInstances.filter(
(item) => item.serverUrl !== instance.serverUrl
)
instances.unshift(instance)
if (instances.length > MAX_RECENT_INSTANCES) {
instances.length = MAX_RECENT_INSTANCES
}
this.recentInstances$.next(instances)
await this.saveRecentInstances()
console.log(
`Updated recent instances. Current count: ${instances.length}`
)
} catch (error) {
console.error("Failed to update recent instance:", error)
}
}
}

View file

@ -15,6 +15,10 @@ dist-ssr
bundle.zip
manifest.json
src-tauri/hopp_bundle.zip
src-tauri/hopp_manifest.json
src-tauri/hoppscotch-desktop-data
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View file

@ -1,19 +1,22 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke } from "@tauri-apps/api/core"
async function download(options) {
return await invoke('plugin:appload|download', { options });
return await invoke("plugin:appload|download", { options })
}
async function load(options) {
return await invoke('plugin:appload|load', { options });
return await invoke("plugin:appload|load", { options })
}
async function close(options) {
return await invoke("plugin:appload|close", { options })
}
async function close(options) {
return await invoke('plugin:appload|close', { options });
}
async function remove(options) {
return await invoke('plugin:appload|remove', { options });
return await invoke("plugin:appload|remove", { options })
}
async function clear() {
return await invoke('plugin:appload|clear');
return await invoke("plugin:appload|clear")
}
export { clear, close, download, load, remove };

View file

@ -1,8 +1,8 @@
import "./style.css";
import App from "./App.svelte";
import "./style.css"
import App from "./App.svelte"
const app = new App({
target: document.getElementById("app"),
});
})
export default app;
export default app

View file

@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
const host = process.env.TAURI_DEV_HOST;
const host = process.env.TAURI_DEV_HOST
// https://vitejs.dev/config/
export default defineConfig({
@ -15,10 +15,12 @@ export default defineConfig({
host: host || false,
port: 1420,
strictPort: true,
hmr: host ? {
protocol: 'ws',
hmr: host
? {
protocol: "ws",
host,
port: 1421
} : undefined,
port: 1421,
}
: undefined,
},
})

View file

@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import { invoke } from "@tauri-apps/api/core"
export interface DownloadOptions {
serverUrl: string
@ -47,22 +47,24 @@ export interface RemoveResponse {
bundleName: string
}
export async function download(options: DownloadOptions): Promise<DownloadResponse> {
return await invoke<DownloadResponse>('plugin:appload|download', { options })
export async function download(
options: DownloadOptions
): Promise<DownloadResponse> {
return await invoke<DownloadResponse>("plugin:appload|download", { options })
}
export async function load(options: LoadOptions): Promise<LoadResponse> {
return await invoke<LoadResponse>('plugin:appload|load', { options })
return await invoke<LoadResponse>("plugin:appload|load", { options })
}
export async function close(options: CloseOptions): Promise<CloseResponse> {
return await invoke<CloseResponse>('plugin:appload|close', { options })
return await invoke<CloseResponse>("plugin:appload|close", { options })
}
export async function remove(options: RemoveOptions): Promise<RemoveResponse> {
return await invoke<RemoveResponse>('plugin:appload|remove', { options })
return await invoke<RemoveResponse>("plugin:appload|remove", { options })
}
export async function clear(): Promise<void> {
return await invoke('plugin:appload|clear')
return await invoke("plugin:appload|clear")
}

View file

@ -318,6 +318,16 @@
"const": "deny-close",
"markdownDescription": "Denies the close command without any pre-configured scope."
},
{
"description": "Enables the close command without any pre-configured scope.",
"type": "string",
"const": "allow-close"
},
{
"description": "Denies the close command without any pre-configured scope.",
"type": "string",
"const": "deny-close"
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",

View file

@ -1,31 +1,31 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { cwd } from 'process'
import typescript from '@rollup/plugin-typescript'
import { readFileSync } from "fs"
import { join } from "path"
import { cwd } from "process"
import typescript from "@rollup/plugin-typescript"
const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8"))
export default {
input: 'guest-js/index.ts',
input: "guest-js/index.ts",
output: [
{
file: pkg.exports.import,
format: 'esm'
format: "esm",
},
{
file: pkg.exports.require,
format: 'cjs'
}
format: "cjs",
},
],
plugins: [
typescript({
declaration: true,
declarationDir: `./${pkg.exports.import.split('/')[0]}`
})
declarationDir: `./${pkg.exports.import.split("/")[0]}`,
}),
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
]
...Object.keys(pkg.peerDependencies || {}),
],
}

View file

@ -1,4 +1,4 @@
(() => {
console.log('Setting desktop kernel mode');
window.__KERNEL_MODE__ = 'desktop';
})();
;(() => {
console.log("Setting desktop kernel mode")
window.__KERNEL_MODE__ = "desktop"
})()

View file

@ -1,24 +1,24 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke } from "@tauri-apps/api/core"
var MediaType;
(function (MediaType) {
MediaType["TEXT_PLAIN"] = "text/plain";
MediaType["TEXT_HTML"] = "text/html";
MediaType["TEXT_CSS"] = "text/css";
MediaType["TEXT_CSV"] = "text/csv";
MediaType["APPLICATION_JSON"] = "application/json";
MediaType["APPLICATION_LD_JSON"] = "application/ld+json";
MediaType["APPLICATION_XML"] = "application/xml";
MediaType["TEXT_XML"] = "text/xml";
MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded";
MediaType["APPLICATION_OCTET"] = "application/octet-stream";
MediaType["MULTIPART_FORM"] = "multipart/form-data";
})(MediaType || (MediaType = {}));
let MediaType
;(function (MediaType) {
MediaType["TEXT_PLAIN"] = "text/plain"
MediaType["TEXT_HTML"] = "text/html"
MediaType["TEXT_CSS"] = "text/css"
MediaType["TEXT_CSV"] = "text/csv"
MediaType["APPLICATION_JSON"] = "application/json"
MediaType["APPLICATION_LD_JSON"] = "application/ld+json"
MediaType["APPLICATION_XML"] = "application/xml"
MediaType["TEXT_XML"] = "text/xml"
MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded"
MediaType["APPLICATION_OCTET"] = "application/octet-stream"
MediaType["MULTIPART_FORM"] = "multipart/form-data"
})(MediaType || (MediaType = {}))
async function execute(request) {
return await invoke('plugin:relay|execute', { request });
return await invoke("plugin:relay|execute", { request })
}
async function cancel(requestId) {
return await invoke('plugin:relay|cancel', { requestId });
return await invoke("plugin:relay|cancel", { requestId })
}
export { MediaType, cancel, execute };
export { MediaType, cancel, execute }

View file

@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import { invoke } from "@tauri-apps/api/core"
export type Method =
| "GET" // Retrieve resource
@ -95,17 +95,46 @@ export enum MediaType {
TEXT_XML = "text/xml",
APPLICATION_FORM = "application/x-www-form-urlencoded",
APPLICATION_OCTET = "application/octet-stream",
MULTIPART_FORM = "multipart/form-data"
MULTIPART_FORM = "multipart/form-data",
}
export type ContentType =
| { kind: "text"; content: string; mediaType: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV }
| { kind: "json"; content: unknown; mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON }
| { kind: "xml"; content: string; mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML }
| {
kind: "text"
content: string
mediaType:
| MediaType.TEXT_PLAIN
| MediaType.TEXT_HTML
| MediaType.TEXT_CSS
| MediaType.TEXT_CSV
}
| {
kind: "json"
content: unknown
mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON
}
| {
kind: "xml"
content: string
mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML
}
| { kind: "form"; content: FormData; mediaType: MediaType.APPLICATION_FORM }
| { kind: "binary"; content: Uint8Array; mediaType: MediaType.APPLICATION_OCTET | string; filename?: string }
| { kind: "multipart"; content: FormData; mediaType: MediaType.MULTIPART_FORM }
| { kind: "urlencoded"; content: string; mediaType: MediaType.APPLICATION_FORM }
| {
kind: "binary"
content: Uint8Array
mediaType: MediaType.APPLICATION_OCTET | string
filename?: string
}
| {
kind: "multipart"
content: FormData
mediaType: MediaType.MULTIPART_FORM
}
| {
kind: "urlencoded"
content: string
mediaType: MediaType.APPLICATION_FORM
}
| { kind: "stream"; content: ReadableStream; mediaType: string }
export interface ResponseBody {
@ -244,7 +273,7 @@ export interface Response {
expires?: Date
secure?: boolean
httpOnly?: boolean
sameSite?: 'Strict' | 'Lax' | 'None'
sameSite?: "Strict" | "Lax" | "None"
}>
body: ResponseBody
@ -277,13 +306,13 @@ export type RelayError =
| { kind: "abort"; message: string }
export type RequestResult =
| { kind: 'success'; response: Response }
| { kind: 'error'; error: RelayError }
| { kind: "success"; response: Response }
| { kind: "error"; error: RelayError }
export async function execute(request: Request): Promise<RequestResult> {
return await invoke<RequestResult>('plugin:relay|execute', { request })
return await invoke<RequestResult>("plugin:relay|execute", { request })
}
export async function cancel(requestId: number): Promise<void> {
return await invoke<void>('plugin:relay|cancel', { requestId })
return await invoke<void>("plugin:relay|cancel", { requestId })
}

View file

@ -1,31 +1,31 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { cwd } from 'process'
import typescript from '@rollup/plugin-typescript'
import { readFileSync } from "fs"
import { join } from "path"
import { cwd } from "process"
import typescript from "@rollup/plugin-typescript"
const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8"))
export default {
input: 'guest-js/index.ts',
input: "guest-js/index.ts",
output: [
{
file: pkg.exports.import,
format: 'esm'
format: "esm",
},
{
file: pkg.exports.require,
format: 'cjs'
}
format: "cjs",
},
],
plugins: [
typescript({
declaration: true,
declarationDir: `./${pkg.exports.import.split('/')[0]}`
})
declarationDir: `./${pkg.exports.import.split("/")[0]}`,
}),
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
]
...Object.keys(pkg.peerDependencies || {}),
],
}

View file

@ -212,6 +212,11 @@ pub fn run() {
quit_app,
backup::check_and_backup_on_version_change,
updater::check_for_updates,
updater::download_and_install_update,
updater::restart_application,
updater::cancel_update,
updater::get_download_progress,
updater::is_portable_mode,
path::get_config_dir,
path::get_latest_dir,
path::get_instance_dir,

View file

@ -1,53 +1,308 @@
use crate::{dialog, util};
use native_dialog::MessageType;
use tauri_plugin_updater::UpdaterExt;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::{Update, UpdaterExt};
use tokio::sync::Mutex;
/// Check for updates using the updater and return whether updates are available
/// This mimics the behavior of `checkForUpdates` in `updater.ts` but uses native dialogs when needed
#[tauri::command]
pub async fn check_for_updates(app: tauri::AppHandle) -> Result<bool, String> {
tracing::info!("Checking for portable updates");
let updater = match app.updater() {
Ok(updater) => updater,
Err(e) => {
tracing::error!(error = %e, "Failed to initialize updater");
return Err(format!("Failed to initialize updater: {}", e));
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub available: bool,
pub current_version: String,
pub latest_version: Option<String>,
pub release_notes: Option<String>,
}
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub downloaded: u64,
pub total: Option<u64>,
pub percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum UpdateEvent {
CheckStarted,
CheckCompleted {
info: UpdateInfo,
},
CheckFailed {
error: String,
},
DownloadStarted {
#[serde(rename = "totalBytes")]
total_bytes: Option<u64>,
},
DownloadProgress {
progress: DownloadProgress,
},
DownloadCompleted,
InstallStarted,
InstallCompleted,
RestartRequired,
UpdateCancelled,
Error {
message: String,
},
}
// Global state for tracking update progress
// TODO: See if it's possible to let Tauri handle this state management
static UPDATE_STATE: std::sync::LazyLock<Arc<Mutex<Option<Update>>>> =
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
static DOWNLOAD_STATE: std::sync::LazyLock<Arc<Mutex<DownloadProgress>>> =
std::sync::LazyLock::new(|| {
Arc::new(Mutex::new(DownloadProgress {
downloaded: 0,
total: None,
percentage: 0.0,
}))
});
/// Check for updates and returns basic information
/// For portable mode: Shows native dialog if update found
/// For standard mode: Just returns information, UI handles the rest
#[tauri::command]
pub async fn check_for_updates(
app: AppHandle,
show_native_dialog: bool,
) -> Result<UpdateInfo, String> {
tracing::info!(portable_dialog = show_native_dialog, "Checking for updates");
let _ = app.emit("updater-event", UpdateEvent::CheckStarted);
let updater = app.updater().map_err(|e| {
let error_msg = format!("Failed to initialize updater: {}", e);
tracing::error!("{}", error_msg);
let _ = app.emit(
"updater-event",
UpdateEvent::CheckFailed {
error: error_msg.clone(),
},
);
error_msg
})?;
match updater.check().await {
Ok(Some(update)) => {
tracing::info!(
current_version = app.package_info().version.to_string(),
update_version = update.version.to_string(),
"Update available"
let current_version = app.package_info().version.to_string();
let latest_version = update.version.to_string();
let release_notes = update.body.clone();
tracing::info!(current_version, latest_version, "Update available");
{
let mut state = UPDATE_STATE.lock().await;
*state = Some(update);
}
let update_info = UpdateInfo {
available: true,
current_version,
latest_version: Some(latest_version.clone()),
release_notes,
};
if show_native_dialog {
handle_portable_update_dialog(&app, &latest_version).await?;
}
let _ = app.emit(
"updater-event",
UpdateEvent::CheckCompleted {
info: update_info.clone(),
},
);
Ok(update_info)
}
Ok(None) => {
tracing::info!("No updates available");
let update_info = UpdateInfo {
available: false,
current_version: app.package_info().version.to_string(),
latest_version: None,
release_notes: None,
};
let _ = app.emit(
"updater-event",
UpdateEvent::CheckCompleted {
info: update_info.clone(),
},
);
Ok(update_info)
}
Err(e) => {
let error_msg = format!("Failed to check for updates: {}", e);
tracing::error!("{}", error_msg);
let _ = app.emit(
"updater-event",
UpdateEvent::CheckFailed {
error: error_msg.clone(),
},
);
Err(error_msg)
}
}
}
/// Download and install update (for standard mode)
/// Emits progress events that the frontend can listen to
#[tauri::command]
pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> {
tracing::info!("Starting update download and installation");
let update = {
let mut state = UPDATE_STATE.lock().await;
state.take().ok_or("No update available to install")?
};
let _ = app.emit(
"updater-event",
UpdateEvent::DownloadStarted { total_bytes: None },
);
{
let mut download_state = DOWNLOAD_STATE.lock().await;
download_state.downloaded = 0;
download_state.total = None;
download_state.percentage = 0.0;
}
let app_progress = app.clone();
let app_callback = app.clone();
update
.download_and_install(
move |chunk_length, content_length| {
let app = app_progress.clone();
tauri::async_runtime::spawn(async move {
let mut download_state = DOWNLOAD_STATE.lock().await;
if let Some(total) = content_length {
if download_state.total.is_none() {
download_state.total = Some(total);
let _ = app.emit(
"updater-event",
UpdateEvent::DownloadStarted {
total_bytes: Some(total),
},
);
}
}
download_state.downloaded += chunk_length as u64;
if let Some(total) = download_state.total {
download_state.percentage =
(download_state.downloaded as f64 / total as f64) * 100.0;
}
let progress = download_state.clone();
// Release the lock before emitting
// TODO: See if it's possible to not have a lock at all
drop(download_state);
let _ = app.emit("updater-event", UpdateEvent::DownloadProgress { progress });
});
},
move || {
let app = app_callback.clone();
tauri::async_runtime::spawn(async move {
let _ = app.emit("updater-event", UpdateEvent::DownloadCompleted);
let _ = app.emit("updater-event", UpdateEvent::InstallStarted);
let _ = app.emit("updater-event", UpdateEvent::RestartRequired);
});
},
)
.await
.map_err(|e| {
let error_msg = format!("Failed to download and install update: {}", e);
let _ = app.emit(
"updater-event",
UpdateEvent::Error {
message: error_msg.clone(),
},
);
error_msg
})?;
tracing::info!("Update download and installation completed successfully");
Ok(())
}
/// Restart the application (for standard mode)
#[tauri::command]
pub async fn restart_application() -> Result<(), String> {
tracing::info!("Restarting application");
tauri::process::restart(&tauri::Env::default());
// This function never returns because the app restarts,
// so it's safe to allow `unreachable_code` here.
#[allow(unreachable_code)]
Ok(())
}
/// Cancel any ongoing update process
#[tauri::command]
pub async fn cancel_update(app: AppHandle) -> Result<(), String> {
tracing::info!("Cancelling update");
{
let mut state = UPDATE_STATE.lock().await;
*state = None;
}
{
let mut download_state = DOWNLOAD_STATE.lock().await;
download_state.downloaded = 0;
download_state.total = None;
download_state.percentage = 0.0;
}
let _ = app.emit("updater-event", UpdateEvent::UpdateCancelled);
Ok(())
}
/// Get current download progress (for polling if needed)
#[tauri::command]
pub async fn get_download_progress() -> Result<DownloadProgress, String> {
let download_state = DOWNLOAD_STATE.lock().await;
Ok(download_state.clone())
}
/// Check if we're running in portable mode (for frontend convenience)
#[tauri::command]
pub fn is_portable_mode() -> bool {
cfg!(feature = "portable")
}
/// Helper function to handle portable mode update dialog
async fn handle_portable_update_dialog(
_app: &AppHandle,
latest_version: &str,
) -> Result<(), String> {
let download_url = "https://hoppscotch.com/download";
let message = format!(
"An update (version {}) is available for Hoppscotch Desktop (Portable).\n\nWould you like to download it now?\n\n• Yes = Download now\n• No = Remind me later",
update.version
latest_version
);
if dialog::confirm("Download Update", &message, MessageType::Info) {
if let None = util::open_link(download_url) {
if util::open_link(download_url).is_none() {
dialog::error(&format!(
"Failed to open download page. Please visit {}",
download_url
));
return Err(format!("Failed to open download URL"));
return Err("Failed to open download URL".to_string());
}
}
Ok(true)
}
Ok(None) => {
tracing::info!("No updates available");
Ok(false)
}
Err(e) => {
tracing::error!(error = %e, "Failed to check for updates");
Err(format!("Failed to check for updates: {}", e))
}
}
Ok(())
}

View file

@ -0,0 +1,286 @@
import { ref } from "vue"
import { load, download, close } from "@hoppscotch/plugin-appload"
import { getVersion } from "@tauri-apps/api/app"
import { invoke } from "@tauri-apps/api/core"
import { DesktopPersistenceService } from "~/services/persistence.service"
import { InstanceStoreMigrationService } from "~/services/instance-store-migration.service"
import type {
Instance,
ConnectionState,
} from "@hoppscotch/common/platform/instance"
import { VENDORED_INSTANCE_CONFIG } from "@hoppscotch/common/platform/instance"
export enum AppState {
LOADING = "loading",
UPDATE_AVAILABLE = "update_available",
UPDATE_IN_PROGRESS = "update_in_progress",
UPDATE_READY = "update_ready",
ERROR = "error",
LOADED = "loaded",
}
export function useAppInitialization() {
const persistence = DesktopPersistenceService.getInstance()
const migration = InstanceStoreMigrationService.getInstance()
const appState = ref<AppState>(AppState.LOADING)
const error = ref("")
const statusMessage = ref("Initializing...")
const appVersion = ref("...")
const saveConnectionState = async (state: ConnectionState) => {
try {
await persistence.setConnectionState(state)
} catch (err) {
console.error("Failed to save connection state:", err)
}
}
const findMostRecentInstance = (
instances: Instance[],
targetUrl: string
): Instance | null => {
return (
instances.find(
(instance) =>
instance.serverUrl === targetUrl ||
instance.serverUrl.includes(targetUrl) ||
targetUrl.includes(instance.serverUrl)
) || null
)
}
const loadVendoredInstance = async () => {
try {
statusMessage.value = "Loading Hoppscotch Desktop..."
await saveConnectionState({
status: "connected",
instance: VENDORED_INSTANCE_CONFIG,
})
console.log("Loading vendored app...")
const loadResp = await load({
bundleName: VENDORED_INSTANCE_CONFIG.bundleName!,
window: { title: "Hoppscotch" },
})
if (!loadResp.success) {
throw new Error("Failed to load Hoppscotch Vendored")
}
console.log("Vendored app loaded successfully")
close({ windowLabel: "main" })
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
console.error("Error loading vendored app:", errorMessage)
error.value = errorMessage
await saveConnectionState({
status: "error",
target: "Vendored",
message: errorMessage,
})
appState.value = AppState.ERROR
}
}
const loadVendoredIfMatches = async (instance: Instance) => {
if (
instance.kind === "vendored" ||
instance.bundleName === VENDORED_INSTANCE_CONFIG.bundleName
) {
await loadVendoredInstance()
} else {
try {
statusMessage.value = `Loading ${instance.displayName}...`
await saveConnectionState({
status: "connecting",
target: instance.serverUrl,
})
await download({ serverUrl: instance.serverUrl })
const loadResp = await load({
bundleName: instance.bundleName!,
window: { title: "Hoppscotch" },
})
if (!loadResp.success) {
throw new Error(`Failed to load ${instance.displayName}`)
}
await saveConnectionState({
status: "connected",
instance: instance,
})
console.log(`Successfully loaded instance: ${instance.displayName}`)
close({ windowLabel: "main" })
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
console.error(
`Failed to load instance ${instance.displayName}:`,
errorMessage
)
await saveConnectionState({
status: "error",
target: instance.serverUrl,
message: errorMessage,
})
console.log("Falling back to vendored instance")
await loadVendoredInstance()
}
}
}
const loadRecent = async () => {
try {
statusMessage.value = "Loading application..."
const connectionState = await persistence.getConnectionState()
const recentInstances = await persistence.getRecentInstances()
console.log("Current connection state:", connectionState)
console.log("Recent instances:", recentInstances)
if (connectionState) {
switch (connectionState.status) {
case "connected":
if (connectionState.instance) {
statusMessage.value = `Connecting to ${connectionState.instance.displayName}...`
try {
await loadVendoredIfMatches(connectionState.instance)
return
} catch (err) {
console.warn(
"Failed to load previously connected instance:",
err
)
}
}
break
case "connecting":
if (connectionState.target) {
statusMessage.value = `Resuming connection to ${connectionState.target}...`
const targetInstance = findMostRecentInstance(
recentInstances,
connectionState.target
)
if (targetInstance) {
try {
await loadVendoredIfMatches(targetInstance)
return
} catch (err) {
console.warn("Failed to resume connection:", err)
}
}
}
break
case "error":
console.warn("Previous connection failed:", connectionState.message)
break
case "idle":
default:
break
}
}
const mostRecentInstance = recentInstances[0]
if (mostRecentInstance) {
statusMessage.value = `Connecting to ${mostRecentInstance.displayName}...`
try {
await loadVendoredIfMatches(mostRecentInstance)
return
} catch (err) {
console.warn("Failed to load most recent instance:", err)
}
}
console.log("No recent instances found, loading vendored as fallback")
await loadVendoredInstance()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
console.error("Error in loadRecent:", errorMessage)
error.value = errorMessage
appState.value = AppState.ERROR
}
}
const performBasicInitialization = async () => {
try {
appVersion.value = await getVersion()
} catch (error) {
console.error("Failed to get app version:", error)
appVersion.value = "unknown"
}
statusMessage.value = "Checking for version changes..."
try {
await invoke("check_and_backup_on_version_change")
console.log("Version backup check completed")
} catch (err) {
console.warn("Version backup check failed:", err)
}
statusMessage.value = "Running data migration..."
await migration.initialize()
const migrationStatus = migration.getMigrationStatus()
if (migrationStatus.value.status === "failed") {
throw new Error(
`Migration failed: ${migration.getMigrationError().value}`
)
}
statusMessage.value = "Initializing stores..."
await persistence.init()
}
const initialize = async (customLogic?: () => Promise<void>) => {
appState.value = AppState.LOADING
error.value = ""
try {
await performBasicInitialization()
if (customLogic) {
await customLogic()
} else {
await loadRecent()
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
console.error("Initialization error:", errorMessage)
error.value = errorMessage
appState.value = AppState.ERROR
}
}
return {
appState,
error,
statusMessage,
appVersion,
persistence,
migration,
saveConnectionState,
findMostRecentInstance,
loadVendoredInstance,
loadVendoredIfMatches,
loadRecent,
performBasicInitialization,
initialize,
}
}

View file

@ -9,6 +9,10 @@ import "@fontsource-variable/inter"
import "@fontsource-variable/material-symbols-rounded"
import "@fontsource-variable/roboto-mono"
import { initKernel } from "@hoppscotch/kernel"
const app = createApp(App)
app.use(router)
app.mount("#app")
initKernel("desktop")

View file

@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from "vue-router"
import Home from "./views/Home.vue"
import { invoke } from "@tauri-apps/api/core"
const router = createRouter({
history: createWebHistory(),
@ -7,7 +7,24 @@ const router = createRouter({
{
path: "/",
name: "home",
component: Home,
component: async () => {
try {
const isPortable = await invoke<boolean>("is_portable_mode")
// Dynamic import because otherwise `updater`
// tends to experience weird race conditions,
// not sure how or why
return isPortable
? import("./views/PortableHome.vue")
: import("./views/StandardHome.vue")
} catch (error) {
console.error(
"Failed to detect portable mode, defaulting to standard:",
error
)
return import("./views/StandardHome.vue")
}
},
},
],
})

View file

@ -0,0 +1,431 @@
import { BehaviorSubject, Observable } from "rxjs"
import { computed } from "vue"
import { LazyStore } from "@tauri-apps/plugin-store"
import {
getInstanceDir,
getConfigDir,
getStoreDir,
getLatestDir,
Store,
} from "~/kernel/store"
import * as E from "fp-ts/Either"
import { join } from "@tauri-apps/api/path"
import { exists, copyFile, remove, readDir, mkdir } from "@tauri-apps/plugin-fs"
const STORE_NAMESPACE = "hoppscotch-desktop.v1"
const MIGRATION_NAMESPACE = "migration.v1"
const CURRENT_STORE_VERSION = 1
type LegacyServerInstance = {
type: "server"
serverUrl: string
displayName: string
version: string
lastUsed: string
bundleName?: string
}
type LegacyCloudInstance = {
type: "cloud"
displayName: string
version: string
}
type LegacyInstanceKind = LegacyServerInstance | LegacyCloudInstance
type LegacyConnectionState =
| { status: "idle" }
| { status: "connecting"; target: string }
| { status: "connected"; instance: LegacyInstanceKind }
| { status: "error"; target: string; message: string }
type LegacyUpdateState = {
status: string
version?: string
message?: string
progress?: {
downloaded: number
total?: number
}
}
export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored"
export type Instance = {
kind: InstanceKind
serverUrl: string
displayName: string
version: string
lastUsed: string
bundleName?: string
}
export type ConnectionState =
| { status: "idle" }
| { status: "connecting"; target: string }
| { status: "connected"; instance: Instance }
| { status: "error"; target: string; message: string }
export type MigrationStatus =
| { status: "pending" }
| { status: "in-progress" }
| { status: "completed" }
| { status: "failed"; error: string }
export class InstanceStoreMigrationService {
private static instance: InstanceStoreMigrationService
private status$ = new BehaviorSubject<MigrationStatus>({ status: "pending" })
private migrationLock = false
private constructor() {}
public static getInstance(): InstanceStoreMigrationService {
if (!InstanceStoreMigrationService.instance) {
InstanceStoreMigrationService.instance =
new InstanceStoreMigrationService()
}
return InstanceStoreMigrationService.instance
}
async initialize(): Promise<void> {
if (this.migrationLock) {
console.log("Migration already in progress, skipping...")
return
}
this.migrationLock = true
try {
const initResult = await Store.init()
if (E.isLeft(initResult)) {
console.error(`Failed to initialize kernel store: ${initResult.left}`)
// Don't fail the migration, just log and continue,
// since there's a chance no services have started just yet,
// better to continue and fail with legitimate critical reasons,
// rather than failing here and not being able to see the actual cause.
}
const isMigrated = await this.isMigrationComplete()
if (isMigrated) {
console.log("Migration already completed")
this.status$.next({ status: "completed" })
return
}
await this.performMigration()
} catch (error) {
console.error("Migration error:", error)
// Even if migration fails, mark as completed to prevent retry loops,
// so there won't be partial state from the "source".
// There's always a way to restart migration is the source is safe.
await this.markMigrationComplete()
this.status$.next({ status: "completed" })
} finally {
this.migrationLock = false
}
}
private async isMigrationComplete(): Promise<boolean> {
try {
const versionResult = await Store.get<number>(
MIGRATION_NAMESPACE,
"migrationVersion"
)
const currentVersion = E.isRight(versionResult)
? versionResult.right || 0
: 0
return currentVersion >= CURRENT_STORE_VERSION
} catch {
return false
}
}
private async markMigrationComplete(): Promise<void> {
try {
await Store.set(
MIGRATION_NAMESPACE,
"migrationVersion",
CURRENT_STORE_VERSION
)
} catch (error) {
console.error("Failed to mark migration as complete:", error)
}
}
private async performMigration(): Promise<void> {
this.status$.next({ status: "in-progress" })
try {
// Ensure directory structure exists
// (shouldn't fail, but good to check just in case)
await this.ensureDirectoryStructure()
// Migrate data from old stores.
// NOTE: This can fail if the store is using default path,
// and not the full specificed path.
// Remember to check that first.
await this.migrateDataSafely()
// Move .hoppscotch.store files
// (shouldn't fail, but good to check just in case)
await this.migrateHoppscotchStoreFiles()
// Mark migration as complete before cleanup
// (shouldn't fail, but good to check just in case)
await this.markMigrationComplete()
// Clean up old files
// (best effort, don't fail if this fails)
await this.cleanupOldFilesSafely()
this.status$.next({ status: "completed" })
console.log("Migration completed successfully")
} catch (error) {
console.error("Migration failed:", error)
// Mark as completed anyway to prevent retry loops
// (same reasoning as in init)
await this.markMigrationComplete()
this.status$.next({ status: "completed" })
}
}
private async ensureDirectoryStructure(): Promise<void> {
try {
const latestDir = await getLatestDir()
const storeDir = await getStoreDir()
const instanceDir = await getInstanceDir()
// Create directories if they don't exist
for (const dir of [latestDir, storeDir, instanceDir]) {
try {
const dirExists = await exists(dir)
if (!dirExists) {
await mkdir(dir, { recursive: true })
console.log(`Created directory: ${dir}`)
}
} catch (error) {
// Directory might already exist or parent might not exist
console.log(`Directory ${dir} might already exist:`, error)
}
}
} catch (error) {
console.error("Failed to ensure directory structure:", error)
// Continue anyway, directories might already exist
}
}
private async migrateDataSafely(): Promise<void> {
const configDir = await getConfigDir()
const hoppStorePath = await join(configDir, "hopp.store.json")
const desktopStorePath = await join(configDir, "hoppscotch-desktop.store")
let connectionState: ConnectionState = { status: "idle" }
let recentInstances: Instance[] = []
let updateState: any = null
// Read from hopp.store.json if it exists
try {
if (await exists(hoppStorePath)) {
const hoppStore = new LazyStore(hoppStorePath)
await hoppStore.init()
const hoppState =
await hoppStore.get<LegacyConnectionState>("connectionState")
if (hoppState && hoppState.status === "connected") {
connectionState = {
status: "connected",
instance: this.convertInstanceToNewFormat(hoppState.instance),
}
}
const legacyInstances =
await hoppStore.get<LegacyServerInstance[]>("recentInstances")
if (legacyInstances) {
recentInstances = legacyInstances.map((inst) =>
this.convertInstanceToNewFormat(inst)
)
}
}
} catch (error) {
console.log("Could not read hopp.store.json:", error)
}
// Read from hoppscotch-desktop.store if it exists
try {
if (await exists(desktopStorePath)) {
const desktopStore = new LazyStore(desktopStorePath)
await desktopStore.init()
// Only override connection state if we didn't get one from hopp.store
if (connectionState.status === "idle") {
const desktopState =
await desktopStore.get<LegacyConnectionState>("connectionState")
if (desktopState && desktopState.status === "connected") {
connectionState = {
status: "connected",
instance: this.convertInstanceToNewFormat(desktopState.instance),
}
}
}
updateState = await desktopStore.get<LegacyUpdateState>("updateState")
}
} catch (error) {
console.log("Could not read hoppscotch-desktop.store:", error)
}
try {
await Store.set(STORE_NAMESPACE, "connectionState", connectionState)
console.log("Migrated connection state")
} catch (error) {
console.error("Failed to save connection state:", error)
}
try {
await Store.set(STORE_NAMESPACE, "recentInstances", recentInstances)
console.log(`Migrated ${recentInstances.length} recent instances`)
} catch (error) {
console.error("Failed to save recent instances:", error)
}
if (updateState) {
try {
await Store.set(STORE_NAMESPACE, "updateState", updateState)
console.log("Migrated update state")
} catch (error) {
console.error("Failed to save update state:", error)
}
}
}
private async migrateHoppscotchStoreFiles(): Promise<void> {
try {
const configDir = await getConfigDir()
const storeDir = await getStoreDir()
const entries = await readDir(configDir)
const storeFiles = entries
.filter(
(entry: any) =>
!entry.isDirectory && entry.name.endsWith(".hoppscotch.store")
)
.map((entry: any) => entry.name)
for (const fileName of storeFiles) {
try {
const sourcePath = await join(configDir, fileName)
const targetPath = await join(storeDir, fileName)
// Check if source still exists and target doesn't
const sourceExists = await exists(sourcePath)
const targetExists = await exists(targetPath)
if (sourceExists && !targetExists) {
await copyFile(sourcePath, targetPath)
console.log(`Migrated ${fileName} to store directory`)
} else if (sourceExists && targetExists) {
console.log(`${fileName} already exists in target, skipping`)
}
} catch (error) {
console.error(`Failed to migrate ${fileName}:`, error)
// Continue with other files
}
}
} catch (error) {
console.error("Failed to migrate .hoppscotch.store files:", error)
// Non-critical, continue
}
}
private async cleanupOldFilesSafely(): Promise<void> {
const configDir = await getConfigDir()
// List of files to potentially remove
const filesToClean = ["hopp.store.json", "hoppscotch-desktop.store"]
for (const fileName of filesToClean) {
try {
const filePath = await join(configDir, fileName)
if (await exists(filePath)) {
await remove(filePath)
console.log(`Cleaned up old file: ${fileName}`)
}
} catch (error) {
console.log(`Could not remove ${fileName}:`, error)
// Non-critical, continue
}
}
// Also clean up .hoppscotch.store files that were successfully migrated
try {
const storeDir = await getStoreDir()
const entries = await readDir(configDir)
const storeFiles = entries
.filter(
(entry: any) =>
!entry.isDirectory && entry.name.endsWith(".hoppscotch.store")
)
.map((entry: any) => entry.name)
for (const fileName of storeFiles) {
try {
const sourcePath = await join(configDir, fileName)
const targetPath = await join(storeDir, fileName)
// Only remove if successfully copied to target
if ((await exists(targetPath)) && (await exists(sourcePath))) {
await remove(sourcePath)
console.log(`Cleaned up migrated file: ${fileName}`)
}
} catch (error) {
console.log(`Could not remove ${fileName}:`, error)
}
}
} catch (error) {
console.log("Could not clean up .hoppscotch.store files:", error)
}
}
private convertInstanceToNewFormat(
legacyInstance: LegacyInstanceKind
): Instance {
if (legacyInstance.type === "cloud") {
return {
kind: "cloud",
serverUrl: "hoppscotch.io",
displayName: legacyInstance.displayName,
version: legacyInstance.version,
lastUsed: new Date().toISOString(),
}
} else {
return {
kind: "on-prem",
serverUrl: legacyInstance.serverUrl,
displayName: legacyInstance.displayName,
version: legacyInstance.version,
lastUsed: legacyInstance.lastUsed,
bundleName: legacyInstance.bundleName,
}
}
}
public getMigrationStatusStream(): Observable<MigrationStatus> {
return this.status$
}
public getMigrationStatus() {
return computed(() => this.status$.value)
}
public isMigrationCompleted() {
return computed(() => this.status$.value.status === "completed")
}
public getMigrationError() {
return computed(() => {
const status = this.status$.value
return status.status === "failed" ? status.error : null
})
}
}

View file

@ -0,0 +1,262 @@
import * as E from "fp-ts/Either"
import { z } from "zod"
import { StoreError } from "@hoppscotch/kernel"
import { Store } from "~/kernel/store"
import { UpdateState, PortableSettings } from "~/types"
export const STORE_NAMESPACE = "hoppscotch-desktop.v1"
export const STORE_KEYS = {
UPDATE_STATE: "updateState",
CONNECTION_STATE: "connectionState",
RECENT_INSTANCES: "recentInstances",
SCHEMA_VERSION: "schema_version",
PORTABLE_SETTINGS: "portableSettings",
} as const
export const UPDATE_STATE_SCHEMA = z.object({
status: z.enum([
"idle",
"checking",
"available",
"not_available",
"downloading",
"installing",
"ready_to_restart",
"error",
]),
version: z.string().optional(),
message: z.string().optional(),
progress: z
.object({
downloaded: z.number(),
total: z.number().optional(),
})
.optional(),
})
export const INSTANCE_SCHEMA = z.object({
kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]),
serverUrl: z.string(),
displayName: z.string(),
version: z.string(),
lastUsed: z.string(),
bundleName: z.string().optional(),
})
export const CONNECTION_STATE_SCHEMA = z.object({
status: z.enum(["idle", "connecting", "connected", "error"]),
instance: INSTANCE_SCHEMA.optional(),
target: z.string().optional(),
message: z.string().optional(),
})
export const PORTABLE_SETTINGS_SCHEMA = z.object({
disableUpdateNotifications: z.boolean(),
autoSkipWelcome: z.boolean(),
})
export type InstanceKind = z.infer<typeof INSTANCE_SCHEMA>["kind"]
export type Instance = z.infer<typeof INSTANCE_SCHEMA>
export type ConnectionState = z.infer<typeof CONNECTION_STATE_SCHEMA>
interface Migration {
version: number
migrate: () => Promise<void>
}
const migrations: Migration[] = [
{
version: 1,
migrate: async () => {},
},
]
export class DesktopPersistenceService {
private static instance: DesktopPersistenceService
private constructor() {}
public static getInstance(): DesktopPersistenceService {
if (!DesktopPersistenceService.instance) {
DesktopPersistenceService.instance = new DesktopPersistenceService()
}
return DesktopPersistenceService.instance
}
async init(): Promise<E.Either<StoreError, void>> {
const initResult = await Store.init()
if (E.isLeft(initResult)) {
console.error(
"[PersistenceService] Failed to initialize store:",
initResult.left
)
return initResult
}
await this.runMigrations()
return initResult
}
private async runMigrations() {
const versionResult = await Store.get<string>(
STORE_NAMESPACE,
STORE_KEYS.SCHEMA_VERSION
)
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1"
const currentVersion = perhapsVersion ?? "1"
const targetVersion = "1"
if (currentVersion !== targetVersion) {
for (const migration of migrations) {
if (migration.version > parseInt(currentVersion)) {
await migration.migrate()
}
}
await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
}
}
async setUpdateState(state: UpdateState): Promise<void> {
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE,
state
)
if (E.isLeft(result)) {
console.error("Failed to save update state:", result.left)
}
}
async getUpdateState(): Promise<UpdateState | null> {
const result = await Store.get<UpdateState>(
STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE
)
if (E.isRight(result) && result.right) {
return result.right
}
return null
}
async watchUpdateState(
handler: (state: UpdateState) => void
): Promise<() => void> {
const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.UPDATE_STATE)
return watcher.on("change", ({ value }: { value?: unknown }) => {
if (value) {
handler(value as UpdateState)
}
})
}
async setConnectionState(state: ConnectionState): Promise<void> {
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.CONNECTION_STATE,
state
)
if (E.isLeft(result)) {
console.error("Failed to save connection state:", result.left)
}
}
async getConnectionState(): Promise<ConnectionState | null> {
const result = await Store.get<ConnectionState>(
STORE_NAMESPACE,
STORE_KEYS.CONNECTION_STATE
)
if (E.isRight(result) && result.right) {
return result.right
}
return null
}
async setRecentInstances(instances: Instance[]): Promise<void> {
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.RECENT_INSTANCES,
instances
)
if (E.isLeft(result)) {
console.error("Failed to save recent instances:", result.left)
}
}
async getRecentInstances(): Promise<Instance[]> {
const result = await Store.get<Instance[]>(
STORE_NAMESPACE,
STORE_KEYS.RECENT_INSTANCES
)
if (E.isRight(result) && result.right) {
return result.right
}
return []
}
async addRecentInstance(instance: Instance): Promise<void> {
const instances = await this.getRecentInstances()
const existingIndex = instances.findIndex(
(i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl
)
if (existingIndex >= 0) {
instances[existingIndex] = {
...instance,
lastUsed: new Date().toISOString(),
}
} else {
instances.unshift({ ...instance, lastUsed: new Date().toISOString() })
}
const sortedInstances = instances
.sort(
(a, b) =>
new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
)
.slice(0, 10)
await this.setRecentInstances(sortedInstances)
}
async removeRecentInstance(serverUrl: string): Promise<void> {
const instances = await this.getRecentInstances()
const filtered = instances.filter((i) => i.serverUrl !== serverUrl)
await this.setRecentInstances(filtered)
}
async setPortableSettings(settings: PortableSettings): Promise<void> {
console.log("Setting portable settings:", settings)
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS,
settings
)
if (E.isLeft(result)) {
console.error("Failed to save portable settings:", result.left)
throw new Error(`Failed to save portable settings: ${result.left}`)
} else {
console.log("Successfully saved portable settings")
}
}
async getPortableSettings(): Promise<PortableSettings> {
const result = await Store.get<PortableSettings>(
STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS
)
const defaultSettings = {
disableUpdateNotifications: false,
autoSkipWelcome: false,
}
if (E.isRight(result) && result.right) {
console.log("Loaded portable settings from store:", result.right)
return result.right
}
console.log("No portable settings found, using defaults:", defaultSettings)
return defaultSettings
}
}

View file

@ -0,0 +1,73 @@
import { invoke } from "@tauri-apps/api/core"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
export interface UpdateInfo {
available: boolean
currentVersion: string
latestVersion?: string
releaseNotes?: string
}
export interface DownloadProgress {
downloaded: number
total?: number
percentage: number
}
// TODO: Type safety just like `persistence.serivce.ts`?
export type UpdateEvent =
| { type: "CheckStarted" }
| { type: "CheckCompleted"; info: UpdateInfo }
| { type: "CheckFailed"; error: string }
| { type: "DownloadStarted"; totalBytes?: number }
| { type: "DownloadProgress"; progress: DownloadProgress }
| { type: "DownloadCompleted" }
| { type: "InstallStarted" }
| { type: "InstallCompleted" }
| { type: "RestartRequired" }
| { type: "UpdateCancelled" }
| { type: "Error"; message: string }
export class UpdaterClient {
private unlistenFn?: UnlistenFn
async checkForUpdates(showNativeDialog = false): Promise<UpdateInfo> {
return invoke("check_for_updates", { showNativeDialog })
}
async downloadAndInstall(): Promise<void> {
return invoke("download_and_install_update")
}
async restart(): Promise<void> {
return invoke("restart_application")
}
async cancel(): Promise<void> {
return invoke("cancel_update")
}
async getDownloadProgress(): Promise<DownloadProgress> {
return invoke("get_download_progress")
}
async isPortableMode(): Promise<boolean> {
return invoke("is_portable_mode")
}
async listenToUpdates(
callback: (event: UpdateEvent) => void
): Promise<UnlistenFn> {
this.unlistenFn = await listen("updater-event", (event) => {
callback(event.payload as UpdateEvent)
})
return this.unlistenFn
}
stopListening(): void {
if (this.unlistenFn) {
this.unlistenFn()
this.unlistenFn = undefined
}
}
}

View file

@ -32,3 +32,8 @@ export interface UpdateState {
version?: string
message?: string
}
export interface PortableSettings {
disableUpdateNotifications: boolean
autoSkipWelcome: boolean
}

View file

@ -0,0 +1,281 @@
<template>
<div
class="flex flex-col items-center justify-center w-full h-screen bg-primary"
>
<div class="flex flex-col items-center space-y-6 max-w-md text-center">
<AppHeader mode="Portable" />
<div
v-if="showPortableWelcome"
class="flex flex-col items-center space-y-4 max-w-md text-center"
>
<div
class="bg-primaryLight border border-dividerDark rounded-lg p-6 shadow-lg"
>
<h2 class="text-lg font-semibold text-secondaryDark mb-4">
Portable Mode Information
</h2>
<div class="space-y-4 text-sm text-secondary text-left">
<div>
<p class="font-medium text-secondaryDark mb-2">Data Storage</p>
<ul class="space-y-1 text-sm">
<li>
Your data is in the directory you launched this app from.
</li>
<li> This won't sync with the installed version.</li>
</ul>
</div>
<div class="bg-primary rounded p-3 border border-divider">
<p class="font-medium text-secondaryDark text-sm mb-1">
Why no automatic data transfer?
</p>
<p class="text-sm">
Portable apps avoid accessing system directories to maintain
compatibility with enterprise security policies.
</p>
</div>
<div>
<p class="font-medium text-secondaryDark mb-2">
Updates & Migration
</p>
<ul class="space-y-1 text-sm">
<li> Updates require manual download</li>
<li> Data persists when updating portable versions</li>
<li>
Manually copy files to transfer from installed version
</li>
</ul>
</div>
</div>
<div class="flex gap-4 items-center justify-center mt-6">
<label class="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
v-model="portableSettings.disableUpdateNotifications"
@change="onUpdateNotificationsChange"
class="form-checkbox h-4 w-4 text-accent"
/>
<span class="text-sm">Don't notify about updates</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
v-model="portableSettings.autoSkipWelcome"
@change="onAutoSkipChange"
class="form-checkbox h-4 w-4 text-accent"
/>
<span class="text-sm">Don't show this again</span>
</label>
</div>
<div class="flex space-x-3 mt-6">
<HoppButtonPrimary
label="Continue"
class="flex-1"
@click="handlePortableWelcomeContinue"
/>
<HoppButtonSecondary label="Close App" outline @click="closeApp" />
<HoppButtonSecondary
label="Learn More"
outline
@click="openDataMigrationDocs"
/>
</div>
</div>
</div>
<LoadingState
v-else-if="appState === AppState.LOADING"
:message="statusMessage"
/>
<ErrorState
v-else-if="appState === AppState.ERROR"
:error="error"
@retry="initialize"
/>
<VersionInfo
:version="appVersion"
:data-directory="
currentDirectory ? `${currentDirectory}/latest` : undefined
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, watch } from "vue"
import { close } from "@hoppscotch/plugin-appload"
import { invoke } from "@tauri-apps/api/core"
import { Io } from "~/kernel"
import type { PortableSettings } from "~/types"
import {
useAppInitialization,
AppState,
} from "~/composables/useAppInitialization"
import { UpdaterClient, type UpdateEvent } from "~/services/updater.client"
import AppHeader from "./shared/AppHeader.vue"
import LoadingState from "./shared/LoadingState.vue"
import ErrorState from "./shared/ErrorState.vue"
import VersionInfo from "./shared/VersionInfo.vue"
const {
appState,
error,
statusMessage,
appVersion,
persistence,
loadRecent,
initialize,
} = useAppInitialization()
const updaterClient = new UpdaterClient()
const showPortableWelcome = ref(false)
const currentDirectory = ref(".")
const portableSettings = reactive<PortableSettings>({
disableUpdateNotifications: false,
autoSkipWelcome: false,
})
watch(
portableSettings,
(newValue) => {
console.log("portableSettings changed:", newValue)
},
{ deep: true }
)
const onUpdateNotificationsChange = () => {
console.log(
"Update notifications checkbox changed:",
portableSettings.disableUpdateNotifications
)
}
const onAutoSkipChange = () => {
console.log("Auto skip checkbox changed:", portableSettings.autoSkipWelcome)
}
const openDataMigrationDocs = () => {
Io.openExternalLink({
url: "https://docs.hoppscotch.io/documentation/clients/desktop",
})
}
const closeApp = async () => {
try {
await close({ windowLabel: "main" })
} catch (err) {
console.error("Failed to close app:", err)
}
}
const handlePortableWelcomeContinue = async () => {
try {
console.log(
"About to save portable settings:",
JSON.stringify(portableSettings)
)
const settingsToSave: PortableSettings = {
disableUpdateNotifications: portableSettings.disableUpdateNotifications,
autoSkipWelcome: portableSettings.autoSkipWelcome,
}
console.log("Saving portable settings:", settingsToSave)
await persistence.setPortableSettings(settingsToSave)
const savedSettings = await persistence.getPortableSettings()
console.log("Verified saved settings:", savedSettings)
showPortableWelcome.value = false
await loadRecent()
} catch (error) {
console.error("Failed to save portable settings:", error)
showPortableWelcome.value = false
await loadRecent()
}
}
const checkForUpdatesPortable = async () => {
console.log("Checking portable updates, current settings:", portableSettings)
if (portableSettings.disableUpdateNotifications) {
console.log("Update notifications disabled for portable mode")
return
}
statusMessage.value = "Checking for updates..."
try {
await updaterClient.checkForUpdates(true)
console.log("Portable update check completed")
} catch (err) {
console.error("Error checking for portable updates:", err)
}
}
const initializePortableMode = async () => {
try {
const latestDir = await invoke<string>("get_latest_dir")
// NOTE: This is just to show where the files can be.
// This can be flaky sometimes, but should be good enough regardless.
const basePath = latestDir.replace(/[\\\/]latest$/, "")
currentDirectory.value = basePath
} catch (err) {
console.error("Failed to get latest directory:", err)
currentDirectory.value = "."
}
const settings = await persistence.getPortableSettings()
console.log("Loaded portable settings:", settings)
portableSettings.disableUpdateNotifications =
settings.disableUpdateNotifications
portableSettings.autoSkipWelcome = settings.autoSkipWelcome
console.log("Updated reactive portableSettings:", portableSettings)
await checkForUpdatesPortable()
if (!settings.autoSkipWelcome) {
console.log("Showing portable welcome screen")
showPortableWelcome.value = true
return
}
console.log("Auto-skipping welcome screen")
await loadRecent()
}
onMounted(async () => {
// Listen to update events (mainly for error handling)
// Checkout `updater.rs` for more info.
await updaterClient.listenToUpdates((event: UpdateEvent) => {
switch (event.type) {
case "Error":
console.error("Update error:", event.message)
// For portable mode, errors are already handled by native dialogs,
// see `updater.rs`.
break
}
})
await initialize(initializePortableMode)
})
onUnmounted(() => {
updaterClient.stopListening()
})
</script>

View file

@ -0,0 +1,179 @@
<template>
<div
class="flex flex-col items-center justify-center w-full h-screen bg-primary"
>
<div class="flex flex-col items-center space-y-6 max-w-md text-center">
<AppHeader />
<LoadingState
v-if="appState === AppState.LOADING"
:message="statusMessage"
/>
<UpdateFlow
v-else-if="
appState === AppState.UPDATE_AVAILABLE ||
appState === AppState.UPDATE_IN_PROGRESS ||
appState === AppState.UPDATE_READY
"
:state="updateFlowState"
:message="updateMessage"
:progress="downloadProgress"
:show-progress="true"
:show-cancel="appState === AppState.UPDATE_AVAILABLE"
@install="installUpdate"
@restart="restartApp"
@skip="skipUpdate"
@cancel="cancelUpdate"
/>
<ErrorState
v-else-if="appState === AppState.ERROR"
:error="error"
@retry="initialize"
/>
<VersionInfo :version="appVersion" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"
import {
useAppInitialization,
AppState,
} from "~/composables/useAppInitialization"
import {
UpdaterClient,
type UpdateEvent,
type DownloadProgress,
} from "~/services/updater.client"
import AppHeader from "./shared/AppHeader.vue"
import LoadingState from "./shared/LoadingState.vue"
import UpdateFlow from "./shared/UpdateFlow.vue"
import ErrorState from "./shared/ErrorState.vue"
import VersionInfo from "./shared/VersionInfo.vue"
const { appState, error, statusMessage, appVersion, loadRecent, initialize } =
useAppInitialization()
const updaterClient = new UpdaterClient()
const updateMessage = ref("")
const downloadProgress = ref<DownloadProgress>({
downloaded: 0,
total: undefined,
percentage: 0,
})
const updateFlowState = computed(() => {
switch (appState.value) {
case AppState.UPDATE_AVAILABLE:
return "available"
case AppState.UPDATE_IN_PROGRESS:
return downloadProgress.value.percentage < 100
? "downloading"
: "installing"
case AppState.UPDATE_READY:
return "ready"
default:
return "available"
}
})
const installUpdate = async () => {
try {
appState.value = AppState.UPDATE_IN_PROGRESS
await updaterClient.downloadAndInstall()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
error.value = `Failed to install update: ${errorMessage}`
appState.value = AppState.ERROR
}
}
const skipUpdate = async () => {
await loadRecent()
}
const restartApp = async () => {
try {
await updaterClient.restart()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
error.value = `Failed to restart app: ${errorMessage}`
appState.value = AppState.ERROR
}
}
const cancelUpdate = async () => {
try {
await updaterClient.cancel()
await loadRecent()
} catch (err) {
console.error("Failed to cancel update:", err)
await loadRecent()
}
}
const checkForUpdates = async () => {
try {
statusMessage.value = "Checking for updates..."
const updateInfo = await updaterClient.checkForUpdates(false)
if (updateInfo.available) {
console.log("Updates available (standard)")
updateMessage.value =
updateInfo.releaseNotes ||
`Version ${updateInfo.latestVersion} is available`
appState.value = AppState.UPDATE_AVAILABLE
return true
}
return false
} catch (err) {
console.error("Error checking for updates:", err)
// NOTE: No need to show error for update check failures, just continue,
// because `check()` tends to fail quite often due to inexplicable reasons,
// `updater.rs` is far more stable options so continuing will
// let the Rust side handle this.
return false
}
}
const initializeStandardMode = async () => {
const hasUpdates = await checkForUpdates()
if (!hasUpdates) {
await loadRecent()
}
}
onMounted(async () => {
await updaterClient.listenToUpdates((event: UpdateEvent) => {
switch (event.type) {
case "DownloadProgress":
downloadProgress.value = event.progress
break
case "DownloadCompleted":
appState.value = AppState.UPDATE_IN_PROGRESS
break
case "RestartRequired":
appState.value = AppState.UPDATE_READY
break
case "Error":
error.value = event.message
appState.value = AppState.ERROR
break
}
})
await initialize(initializeStandardMode)
})
onUnmounted(() => {
updaterClient.stopListening()
})
</script>

View file

@ -0,0 +1,21 @@
<template>
<div class="flex items-center space-x-4">
<img src="/logo.svg" alt="Hoppscotch" class="h-7 w-7" />
<div class="flex flex-col items-start">
<h1 class="text-2xl font-semibold text-secondaryDark">Hoppscotch</h1>
<p class="text-secondary text-sm">
Desktop {{ mode ? `(${mode})` : "" }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
mode?: string
}
withDefaults(defineProps<Props>(), {
mode: "",
})
</script>

View file

@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center space-y-4">
<IconLucideAlertCircle class="h-16 w-16 text-red-500" />
<div class="text-center">
<h2 class="text-xl font-semibold text-secondaryDark">
Something went wrong
</h2>
<p class="text-red-500 mt-2">{{ error }}</p>
</div>
<HoppButtonPrimary
label="Try Again"
:icon="IconLucideRefreshCw"
@click="$emit('retry')"
/>
</div>
</template>
<script setup lang="ts">
import IconLucideAlertCircle from "~icons/lucide/alert-circle"
import IconLucideRefreshCw from "~icons/lucide/refresh-cw"
interface Props {
error: string
}
defineProps<Props>()
defineEmits<{
retry: []
}>()
</script>

View file

@ -0,0 +1,14 @@
<template>
<div class="flex flex-col items-center space-y-4">
<HoppSmartSpinner />
<p class="text-secondaryDark">{{ message }}</p>
</div>
</template>
<script setup lang="ts">
interface Props {
message: string
}
defineProps<Props>()
</script>

View file

@ -0,0 +1,111 @@
<template>
<div class="flex flex-col items-center space-y-4">
<IconLucideDownload class="h-16 w-16 text-accent" />
<div class="text-center">
<h2 class="text-xl font-semibold text-secondaryDark">Update Available</h2>
<p class="text-secondary mt-1">
{{ message || "A new version of Hoppscotch is available" }}
</p>
</div>
<div
v-if="showProgress && progress && progress.total && progress.downloaded"
class="w-full"
>
<div class="w-full bg-primaryLight rounded-full h-2.5">
<div
class="bg-accent h-2.5 rounded-full transition-all duration-300"
:style="{
width: `${progress.percentage}%`,
}"
></div>
</div>
<div class="flex justify-between text-sm text-secondaryLight mt-1">
<span>{{ Math.round(progress.percentage) }}%</span>
<span class="text-sm">
{{ formatBytes(progress.downloaded) }} /
{{ formatBytes(progress.total) }}
</span>
</div>
</div>
<div
v-else-if="showProgress && progress && progress.downloaded > 0"
class="w-full"
>
<div class="w-full bg-primaryLight rounded-full h-2.5">
<div
class="bg-accent h-2.5 rounded-full animate-pulse"
style="width: 100%"
></div>
</div>
<p class="text-sm text-secondaryLight text-center mt-1">
Downloaded {{ formatBytes(progress.downloaded) }}
</p>
</div>
<div class="flex space-x-2">
<HoppButtonPrimary
v-if="state === 'available'"
label="Install Update"
:icon="IconLucideDownload"
@click="$emit('install')"
/>
<HoppButtonPrimary
v-else-if="state === 'ready'"
label="Restart Now"
:icon="IconLucideRefreshCw"
@click="$emit('restart')"
/>
<HoppButtonSecondary
v-if="state === 'available'"
label="Later"
outline
@click="$emit('skip')"
/>
<HoppButtonSecondary
v-if="showCancel"
label="Cancel"
outline
@click="$emit('cancel')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideDownload from "~icons/lucide/download"
import IconLucideRefreshCw from "~icons/lucide/refresh-cw"
import type { DownloadProgress } from "~/services/updater.client"
interface Props {
state: "available" | "downloading" | "installing" | "ready"
message?: string
progress?: DownloadProgress
showProgress?: boolean
showCancel?: boolean
}
withDefaults(defineProps<Props>(), {
message: "",
showProgress: true,
showCancel: false,
})
defineEmits<{
install: []
restart: []
skip: []
cancel: []
}>()
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
</script>

View file

@ -0,0 +1,15 @@
<template>
<div class="text-secondaryLight text-sm mt-4 text-center">
<p>Version {{ version }}</p>
<p v-if="dataDirectory" class="text-xs mt-1">Data: {{ dataDirectory }}</p>
</div>
</template>
<script setup lang="ts">
interface Props {
version: string
dataDirectory?: string
}
defineProps<Props>()
</script>

View file

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

View file

@ -1,39 +0,0 @@
# 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?
# Sitemap
.sitemap-gen
# Backend Code generation
src/api/generated
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View file

@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

View file

@ -1,16 +0,0 @@
# Tauri + Vue 3 + 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

@ -1,153 +0,0 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1729681848,
"owner": "cachix",
"repo": "devenv",
"rev": "2634c4c9e9226a3fb54550ad4115df1992d502c5",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1729751566,
"owner": "nix-community",
"repo": "fenix",
"rev": "f32a2d484091a6dc98220b1f4a2c2d60b7c97c64",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"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",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1729690727,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "be79af5ec63facf6c7709094db72b253c34e1ac2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1729449015,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "89172919243df199fe237ba0f776c3e3e3d72367",
"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": 1729104314,
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "3c3e88f0f544d6bb54329832616af7eb971b6be6",
"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": 1729715509,
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "40492e15d49b89cf409e2c5536444131fac49429",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,92 +0,0 @@
{ pkgs, lib, config, inputs, ... }:
{
# https://devenv.sh/packages/
packages = with pkgs; [
git
postgresql_16
# BE and Tauri stuff
libsoup
webkitgtk_4_0
# FE and Node stuff
nodejs_22
nodePackages_latest.typescript-language-server
nodePackages_latest.vls
nodePackages_latest.prisma
prisma-engines
# CI
act
# Cargo
cargo-edit
];
# https://devenv.sh/basics/
env = {
APP_GREET = "Hoppscotch";
# 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
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";
};
# https://devenv.sh/scripts/
scripts = {
hello.exec = "echo hello from $APP_GREET";
e.exec = "emacs";
};
enterShell = ''
git --version
'';
# https://devenv.sh/tests/
enterTest = ''
echo "Running tests"
'';
# https://devenv.sh/integrations/dotenv/
dotenv.enable = true;
# https://devenv.sh/languages/
# https://devenv.sh/languages/
languages = {
typescript.enable = true;
javascript = {
enable = true;
pnpm = {
enable = true;
};
npm = {
enable = true;
};
};
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

@ -1,23 +0,0 @@
# 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

@ -1,18 +0,0 @@
overwrite: true
schema: "../../gql-gen/*.gql"
generates:
src/api/generated/graphql.ts:
documents: "src/**/*.graphql"
plugins:
- add:
content: >
/* eslint-disable */
// Auto-generated file (DO NOT EDIT!!!), refer gql-codegen.yml
- typescript
- typescript-operations
- typed-document-node
- typescript-urql-graphcache
src/api/generated/backend-schema.json:
plugins:
- urql-introspection

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hoppscotch - Open source API development ecosystem</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/icon.png" />
</head>
<body>
<div id="app"></div>
<script>
// Shims to make swagger-parser package work
window.global = window
</script>
<script type="module">
import { Buffer } from "buffer"
import process from "process"
// // Shims to make postman-collection work
window.Buffer = Buffer
window.process = process
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,118 +0,0 @@
import { IHTMLTag } from "vite-plugin-html-config"
export const APP_INFO = {
name: "Hoppscotch",
shortDescription: "Open source API development ecosystem",
description:
"Helps you create requests faster, saving precious time on development.",
keywords:
"hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio",
app: {
background: "#202124",
},
social: {
twitter: "@hoppscotch_io",
},
} as const
export const META_TAGS = (env: Record<string, string>): IHTMLTag[] => [
{
name: "keywords",
content: APP_INFO.keywords,
},
{
name: "X-UA-Compatible",
content: "IE=edge, chrome=1",
},
{
name: "name",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "description",
content: APP_INFO.description,
},
{
name: "image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Open Graph tags
{
name: "og:title",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "og:description",
content: APP_INFO.description,
},
{
name: "og:image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Twitter tags
{
name: "twitter:card",
content: "summary_large_image",
},
{
name: "twitter:site",
content: APP_INFO.social.twitter,
},
{
name: "twitter:creator",
content: APP_INFO.social.twitter,
},
{
name: "twitter:title",
content: `${APP_INFO.name}${APP_INFO.shortDescription}`,
},
{
name: "twitter:description",
content: APP_INFO.description,
},
{
name: "twitter:image",
content: `${env.VITE_BASE_URL}/banner.png`,
},
// Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt)
{
name: "application-name",
content: APP_INFO.name,
},
// Windows phone tile icon
{
name: "msapplication-TileImage",
content: `${env.VITE_BASE_URL}/icon.png`,
},
{
name: "msapplication-TileColor",
content: APP_INFO.app.background,
},
{
name: "msapplication-tap-highlight",
content: "no",
},
// iOS Safari
{
name: "apple-mobile-web-app-title",
content: APP_INFO.name,
},
{
name: "apple-mobile-web-app-capable",
content: "yes",
},
{
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
// PWA
{
name: "theme-color",
content: APP_INFO.app.background,
},
{
name: "mask-icon",
content: "/icon.png",
color: APP_INFO.app.background,
},
]

View file

@ -1,86 +0,0 @@
{
"name": "@hoppscotch/selfhost-desktop",
"private": true,
"version": "2025.1.1",
"type": "module",
"scripts": {
"dev:vite": "vite",
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\" --watch",
"dev": "pnpm exec npm-run-all -p -l dev:*",
"build": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
"tauri": "tauri",
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\""
},
"dependencies": {
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-rounded": "5.2.24",
"@fontsource-variable/roboto-mono": "5.2.8",
"@hoppscotch/common": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@platform/auth": "0.1.106",
"@tauri-apps/api": "1.5.1",
"@tauri-apps/cli": "1.5.6",
"@vueuse/core": "10.5.0",
"axios": "1.8.2",
"buffer": "6.0.3",
"dioc": "3.0.2",
"environments.api": "link:@platform/environments/environments.api",
"event": "link:@tauri-apps/api/event",
"fp-ts": "2.16.1",
"lodash-es": "4.17.21",
"process": "0.11.10",
"rxjs": "7.8.1",
"shell": "link:@tauri-apps/api/shell",
"stream-browserify": "3.0.0",
"tauri": "link:@tauri-apps/api/tauri",
"tauri-plugin-store-api": "0.0.0",
"util": "0.12.5",
"verzod": "0.3.0",
"vue": "3.5.22",
"workbox-window": "6.6.0",
"zod": "3.25.32"
},
"devDependencies": {
"@graphql-codegen/add": "5.0.0",
"@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql-graphcache": "2.4.5",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "3.2.0",
"@iconify-json/lucide": "1.2.68",
"@intlify/unplugin-vue-i18n": "6.0.4",
"@rushstack/eslint-patch": "1.14.0",
"@types/lodash-es": "4.17.10",
"@types/node": "24.9.1",
"@typescript-eslint/eslint-plugin": "8.44.1",
"@typescript-eslint/parser": "8.44.1",
"@vitejs/plugin-legacy": "2.3.0",
"@vitejs/plugin-vue": "4.3.1",
"@vue/eslint-config-typescript": "11.0.3",
"autoprefixer": "10.4.16",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint": "8.47.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-vue": "10.5.1",
"npm-run-all": "4.1.5",
"postcss": "8.4.32",
"tailwindcss": "3.3.6",
"typescript": "5.9.3",
"unplugin-fonts": "1.1.1",
"unplugin-icons": "0.14.9",
"unplugin-vue-components": "0.21.0",
"vite": "4.5.0",
"vite-plugin-html-config": "1.0.11",
"vite-plugin-inspect": "0.7.38",
"vite-plugin-pages": "0.26.0",
"vite-plugin-pages-sitemap": "1.6.1",
"vite-plugin-pwa": "1.1.0",
"vite-plugin-static-copy": "0.12.0",
"vite-plugin-vue-layouts": "0.7.0",
"vue-tsc": "1.8.8"
}
}

View file

@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

View file

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,4 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/

File diff suppressed because it is too large Load diff

View file

@ -1,58 +0,0 @@
[package]
name = "hoppscotch-desktop"
version = "25.1.1"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.5.5", features = [] }
[dependencies]
tauri = { version = "1.8.1", features = [
"dialog-save",
"fs-write-file",
"http-all",
"os-all",
"shell-open",
"window-start-dragging",
"http-multipart",
] }
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-window-state = "0.1.1"
hoppscotch-relay = { path = "../../hoppscotch-relay" }
serde_json = "1.0.128"
url = "2.5.2"
hex_color = "3.0.0"
serde = { version = "1.0.210", features = ["derive"] }
dashmap = "5.5.3"
tokio = { version = "1.40.0", features = ["macros"] }
tokio-util = "0.7.12"
log = "0.4.22"
thiserror = "1.0.64"
[dev-dependencies]
tauri = { version = "1.8.1", features = ["devtools", "test"] }
env_logger = "0.11.5"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.52.0", features = [
"Win32_Graphics_Dwm",
"Win32_Foundation",
"Win32_UI_Controls",
] }
winver = "1"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<!-- Obviously needs to be replaced with your app's bundle identifier -->
<string>io.hoppscotch.desktop</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- register the myapp:// and myscheme:// schemes -->
<string>hoppscotch</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

View file

@ -1,90 +0,0 @@
use dashmap::DashMap;
use hoppscotch_relay::{RequestWithMetadata, ResponseWithMetadata};
use serde::Serialize;
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime, State,
};
use thiserror::Error;
use tokio_util::sync::CancellationToken;
#[derive(Default)]
struct InterceptorState {
cancellation_tokens: DashMap<usize, CancellationToken>,
}
#[derive(Debug, Serialize, Error)]
pub enum RunRequestError {
#[error("Request cancelled")]
RequestCancelled,
#[error("Internal server error")]
InternalServerError,
#[error("Relay error: {0}")]
Relay(#[from] hoppscotch_relay::RelayError),
}
#[tauri::command]
async fn run_request(
req: RequestWithMetadata,
state: State<'_, InterceptorState>,
) -> Result<ResponseWithMetadata, RunRequestError> {
let req_id = req.req_id;
let cancel_token = CancellationToken::new();
// NOTE: This will drop reference to an existing cancellation token
// if you send a request with the same request id as an existing one,
// thereby, dropping any means to cancel a running operation with the old token.
// This is done so because, on FE side, we may lose cancel token info upon reloads
// and this allows us to work around that.
state
.cancellation_tokens
.insert(req_id, cancel_token.clone());
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 || hoppscotch_relay::run_request_task(&req, cancel_token_clone)) => {
match res {
Ok(task_result) => Ok(task_result?),
Err(_) => Err(RunRequestError::InternalServerError),
}
},
_ = cancel_token.cancelled() => {
Err(RunRequestError::RequestCancelled)
}
};
state.cancellation_tokens.remove(&req_id);
result
}
#[tauri::command]
fn cancel_request(req_id: usize, state: State<'_, InterceptorState>) {
if let Some((_, cancel_token)) = state.cancellation_tokens.remove(&req_id) {
cancel_token.cancel();
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("hopp_native_interceptor")
.invoke_handler(tauri::generate_handler![run_request, cancel_request])
.setup(|app_handle| {
app_handle.manage(InterceptorState::default());
Ok(())
})
.build()
}

View file

@ -1 +0,0 @@
pub(crate) mod startup;

View file

@ -1,53 +0,0 @@
/// Error handling module for startup-related operations.
///
/// This module defines custom error types and a result type used for startup process of the app.
/// Essentially provides a way to handle and communicate errors
/// that may occur during the initialization and window management phases.
use serde::Serialize;
use thiserror::Error;
/// Represents errors related to window lookup failures.
///
/// Provide more specific information about which window that could not be found.
///
/// Derives `Serialize` mainly for sending it over to the frontend for info/logging purposes.
#[derive(Debug, Error, Serialize)]
pub(crate) enum WindowNotFoundError {
/// Indicates that the `main` window of the app could not be found.
///
/// This typically occurs if there's a mismatch between the expected
/// window labels and the actual windows created by the application.
#[error("No window labeled 'main' found")]
Main,
}
/// Represents errors that can occur during the startup process.
///
/// Derives `Serialize` mainly for sending it over to the frontend for info/logging purposes.
#[derive(Debug, Error, Serialize)]
pub(crate) enum StartupError {
/// Represents errors related to window lookup failures.
#[error("Window not found: {0}")]
WindowNotFound(WindowNotFoundError),
/// Represents a general error from the Tauri runtime.
///
/// This variant is used for any errors originating from Tauri that don't
/// fit into more specific categories.
#[error("Tauri error: {0}")]
Tauri(String),
}
/// Functions that are part of the startup process should return this result type.
/// This allows for consistent error handling and makes it clear that the function
/// is part of the startup flow.
///
/// ```
/// use your_crate::error::{StartupResult, StartupError};
///
/// fn some_startup_function() -> StartupResult<()> {
/// // Function implementation
/// Ok(())
/// }
/// ```
pub(crate) type StartupResult<T> = std::result::Result<T, StartupError>;

View file

@ -1,186 +0,0 @@
use log::{error, info};
use tauri::{Manager, Runtime, Window};
use super::error::{StartupError, StartupResult, WindowNotFoundError};
/// Shows the `main` labeled application window.
///
/// This function is designed to be called as a Tauri command.
///
/// # Arguments
///
/// * `window` - A `Window` instance representing the current window. This is automatically
/// provided by Tauri when the command is invoked.
///
/// # Returns
///
/// Returns a `StartupResult<(), String>`:
/// - `Ok(())` if showing main window operation succeed.
/// - `Err(StartupError)` containing an error message if any operation fails.
///
/// # Errors
///
/// This function will return an error if:
/// - The "main" window is not found.
/// - Showing the main window fails.
///
/// # Example
///
/// ```rust,no_run
/// #[tauri::command]
/// async fn invoke_interop_startup_init(window: tauri::Window) {
/// match interop_startup_init(window).await {
/// Ok(_) => println!("`main` window shown successfully"),
/// Err(e) => eprintln!("Error: {}", e),
/// }
/// }
/// ```
#[tauri::command]
pub async fn interop_startup_init<R: Runtime>(window: Window<R>) -> StartupResult<()> {
let main_window = window.get_window("main").ok_or_else(|| {
error!("No window labeled 'main' found");
StartupError::WindowNotFound(WindowNotFoundError::Main)
})?;
main_window.show().map_err(|e| {
error!("Failed to show `main` window: {}", e);
StartupError::Tauri(format!("Failed to show `main` window: {}", e))
})?;
info!("`main` window shown successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tauri::test::{assert_ipc_response, mock_builder, mock_context, noop_assets};
use tauri::{InvokePayload, WindowBuilder, WindowUrl};
fn create_app<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::App<R> {
builder
.invoke_handler(tauri::generate_handler![interop_startup_init])
.build(mock_context(noop_assets()))
.expect("failed to build mock app")
}
/// Test: Main window shown successfully in isolation
///
/// Rationale:
/// This test verifies the core functionality of `interop_startup_init`.
/// A failure indicates a fundamental issue with the app's initialization process.
///
/// Context:
/// The "main" window is typically the primary interface,
/// so ensuring it shows correctly is important.
///
/// Key Points:
/// - We use a mock Tauri application to isolate the window showing behavior.
/// - The test focuses solely on the "main" window to verify the basic case works correctly.
///
/// Assumptions:
/// - The Tauri runtime is functioning correctly.
/// - A window labeled "main" exists in the application.
/// For this see `tauri.conf.json`:
/// ```json
/// {
/// ...
/// "label": "main",
/// "title": "Hoppscotch",
/// ...
/// ...
/// }
/// ```
///
/// Implications of Failure:
/// 1. The window labeling system is broken.
/// 2. There's an issue with Tauri's window management.
/// 3. The `interop_startup_init` function is not correctly implemented.
#[tokio::test]
async fn test_interop_startup_init_main_window_shown_successfully() {
let app = create_app(mock_builder());
let window = app.get_window("main").expect("`main` window not found");
let result = interop_startup_init(window).await;
assert!(result.is_ok(), "Expected Ok, but got {:?}", result);
}
/// Test: Main window found and shown amongst other windows
///
/// Rationale:
/// This test ensures `interop_startup_init` can correctly identify and show the main window
/// in a more complex scenario with multiple windows.
///
/// Context:
/// As applications grow, they may introduce additional windows for various purposes. The ability
/// to consistently identify and manipulate the main window is important for maintaining
/// expected behavior.
///
/// Key Points:
/// - We create an additional "other" window to simulate another window.
/// - The test verifies that the presence of other windows doesn't interfere with main window operations.
///
/// Assumptions:
/// - The window labeling system consistently identifies the "main" window regardless of other windows.
/// - The order of window creation doesn't affect the ability to find the main window.
///
/// Implications of Failure:
/// 1. The window identification logic breaks with multiple windows.
#[tokio::test]
async fn test_interop_startup_init_main_window_found_amongst_others() {
let app = create_app(mock_builder());
let _ = WindowBuilder::new(&app, "other", WindowUrl::default())
.build()
.expect("Failed to create other window");
let window = app.get_window("other").expect("`other` window not found");
let result = interop_startup_init(window).await;
assert!(result.is_ok(), "Expected `Ok(())`, but got {:?}", result);
}
/// Test: IPC invocation of interop startup init
///
/// Rationale:
/// This test makes sure that `interop_startup_init` can be correctly invoked through Tauri's IPC mechanism.
/// It's important because it verifies the integration between the Rust backend and the frontend
/// that would typically call this function.
///
/// Context:
/// This test simulates scenarios where operations are initiated from the frontend via IPC calls.
///
/// Key Points:
/// - We're testing the IPC invocation, not just the direct function call.
/// - This verifies both the function's behavior and its correct registration with Tauri's IPC system.
///
/// Assumptions:
/// - The Tauri IPC system is functioning correctly.
/// - The `interop_startup_init` function is properly registered as a Tauri command.
///
/// Implications of Failure:
/// 1. There's a mismatch between how the frontend tries to call the function and how it's implemented.
/// 2. The Tauri command registration is incorrect.
/// 3. The function isn't properly handling the IPC context.
#[tokio::test]
async fn test_ipc_interop_startup_init() {
let app = create_app(mock_builder());
let window = app.get_window("main").expect("main window not found");
let payload = InvokePayload {
cmd: "interop_startup_init".into(),
tauri_module: None,
callback: tauri::api::ipc::CallbackFn(0),
error: tauri::api::ipc::CallbackFn(1),
inner: json!(null),
invoke_key: Some("__invoke-key__".to_string()),
};
assert_ipc_response(&window, payload, Ok(()));
}
}

View file

@ -1,7 +0,0 @@
//! Startup management module.
//!
//! This module contains functionality related to managing the application's startup
//! like controlling visibility and lifecycle of the main application windows.
pub(crate) mod init;
pub(crate) mod error;

View file

@ -1 +0,0 @@
pub mod window;

View file

@ -1,390 +0,0 @@
use hex_color::HexColor;
use tauri::{App, Manager, Runtime, Window};
// If anything breaks on macOS, this should be the place which is broken
// We have to override Tauri (Tao) 's built-in NSWindowDelegate implementation with a
// custom implementation so we can emit events on full screen mode changes.
// Our custom implementation tries to mock the Tauri implementation. So please do refer to the relevant parts
// Apple's NSWindowDelegate reference: https://developer.apple.com/documentation/appkit/nswindowdelegate?language=objc
// Tao's Window Delegate Implementation: https://github.com/tauri-apps/tao/blob/dev/src/platform_impl/macos/window_delegate.rs
#[allow(dead_code)]
pub enum ToolbarThickness {
Thick,
Medium,
Thin,
}
const WINDOW_CONTROL_PAD_X: f64 = 15.0;
const WINDOW_CONTROL_PAD_Y: f64 = 23.0;
pub trait WindowExt {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self);
}
#[cfg(target_os = "macos")]
unsafe fn set_transparent_titlebar(id: cocoa::base::id) {
use cocoa::appkit::NSWindow;
id.setTitlebarAppearsTransparent_(cocoa::base::YES);
id.setTitleVisibility_(cocoa::appkit::NSWindowTitleVisibility::NSWindowTitleHidden);
}
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
unsafe impl Sync for UnsafeWindowHandle {}
#[cfg(target_os = "macos")]
fn update_window_theme(window: &tauri::Window, color: HexColor) {
use cocoa::appkit::{
NSAppearance, NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSWindow,
};
let brightness = (color.r as u64 + color.g as u64 + color.b as u64) / 3;
unsafe {
let window_handle = UnsafeWindowHandle(window.ns_window().unwrap());
let _ = window.run_on_main_thread(move || {
let handle = window_handle;
let selected_appearance = if brightness >= 128 {
NSAppearance(NSAppearanceNameVibrantLight)
} else {
NSAppearance(NSAppearanceNameVibrantDark)
};
NSWindow::setAppearance(handle.0 as cocoa::base::id, selected_appearance);
set_window_controls_pos(
handle.0 as cocoa::base::id,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
});
}
}
#[cfg(target_os = "macos")]
fn set_window_controls_pos(window: cocoa::base::id, x: f64, y: f64) {
use cocoa::{
appkit::{NSView, NSWindow, NSWindowButton},
foundation::NSRect,
};
unsafe {
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
button.setFrameOrigin(rect.origin);
}
}
}
impl<R: Runtime> WindowExt for Window<R> {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self) {
unsafe {
let id = self.ns_window().unwrap() as cocoa::base::id;
set_transparent_titlebar(id);
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug)]
struct HoppAppState {
window: Window,
}
#[cfg(target_os = "macos")]
pub fn setup_mac_window(app: &mut App) {
use cocoa::appkit::NSWindow;
use cocoa::base::{id, BOOL};
use cocoa::foundation::NSUInteger;
use objc::runtime::{Object, Sel};
use std::ffi::c_void;
fn with_hopp_app<F: FnOnce(&mut HoppAppState) -> T, T>(this: &Object, func: F) {
let ptr = unsafe {
let x: *mut c_void = *this.get_ivar("hoppApp");
&mut *(x as *mut HoppAppState)
};
func(ptr);
}
let window = app.get_window("main").unwrap();
unsafe {
let ns_win = window.ns_window().unwrap() as id;
let current_delegate: id = ns_win.delegate();
extern "C" fn on_window_should_close(this: &Object, _cmd: Sel, sender: id) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, windowShouldClose: sender]
}
}
extern "C" fn on_window_will_close(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillClose: notification];
}
}
extern "C" fn on_window_did_resize(this: &Object, _cmd: Sel, notification: id) {
unsafe {
with_hopp_app(&*this, |state| {
let id = state.window.ns_window().unwrap() as id;
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidResize: notification];
}
}
extern "C" fn on_window_did_move(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidMove: notification];
}
}
extern "C" fn on_window_did_change_backing_properties(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
}
}
extern "C" fn on_window_did_become_key(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidBecomeKey: notification];
}
}
extern "C" fn on_window_did_resign_key(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidResignKey: notification];
}
}
extern "C" fn on_dragging_entered(this: &Object, _cmd: Sel, notification: id) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, draggingEntered: notification]
}
}
extern "C" fn on_prepare_for_drag_operation(
this: &Object,
_cmd: Sel,
notification: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, prepareForDragOperation: notification]
}
}
extern "C" fn on_perform_drag_operation(this: &Object, _cmd: Sel, sender: id) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, performDragOperation: sender]
}
}
extern "C" fn on_conclude_drag_operation(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, concludeDragOperation: notification];
}
}
extern "C" fn on_dragging_exited(this: &Object, _cmd: Sel, notification: id) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, draggingExited: notification];
}
}
extern "C" fn on_window_will_use_full_screen_presentation_options(
this: &Object,
_cmd: Sel,
window: id,
proposed_options: NSUInteger,
) -> NSUInteger {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
}
}
extern "C" fn on_window_did_enter_full_screen(this: &Object, _cmd: Sel, notification: id) {
unsafe {
with_hopp_app(&*this, |state| {
state.window.emit("did-enter-fullscreen", ()).unwrap();
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
}
}
extern "C" fn on_window_will_enter_full_screen(this: &Object, _cmd: Sel, notification: id) {
unsafe {
with_hopp_app(&*this, |state| {
state.window.emit("will-enter-fullscreen", ()).unwrap();
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
}
}
extern "C" fn on_window_did_exit_full_screen(this: &Object, _cmd: Sel, notification: id) {
unsafe {
with_hopp_app(&*this, |state| {
state.window.emit("did-exit-fullscreen", ()).unwrap();
let id = state.window.ns_window().unwrap() as id;
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidExitFullScreen: notification];
}
}
extern "C" fn on_window_will_exit_full_screen(this: &Object, _cmd: Sel, notification: id) {
unsafe {
with_hopp_app(&*this, |state| {
state.window.emit("will-exit-fullscreen", ()).unwrap();
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
}
}
extern "C" fn on_window_did_fail_to_enter_full_screen(
this: &Object,
_cmd: Sel,
window: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
}
}
extern "C" fn on_effective_appearance_did_change(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
}
}
extern "C" fn on_effective_appearance_did_changed_on_main_thread(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![
super_del,
effectiveAppearanceDidChangedOnMainThread: notification
];
}
}
// extern fn on_dealloc(this: &Object, cmd: Sel) {
// unsafe {
// let super_del: id = *this.get_ivar("super_delegate");
// let _: () = msg_send![super_del, dealloc];
// }
// }
// extern fn on_mark_is_checking_zoomed_in(this: &Object, cmd: Sel) {
// unsafe {
// let super_del: id = *this.get_ivar("super_delegate");
// let _: () = msg_send![super_del, markIsCheckingZoomedIn];
// }
// }
// extern fn on_clear_is_checking_zoomed_in(this: &Object, cmd: Sel) {
// unsafe {
// let super_del: id = *this.get_ivar("super_delegate");
// let _: () = msg_send![super_del, clearIsCheckingZoomedIn];
// }
// }
// Are we deallocing this properly ? (I miss safe Rust :( )
let app_state = HoppAppState { window };
let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
ns_win.setDelegate_(delegate!("MainWindowDelegate", {
window: id = ns_win,
hoppApp: *mut c_void = app_box,
toolbar: id = cocoa::base::nil,
super_delegate: id = current_delegate,
// (dealloc) => on_dealloc as extern fn(&Object, Sel),
// (markIsCheckingZoomedIn) => on_mark_is_checking_zoomed_in as extern fn(&Object, Sel),
// (clearIsCheckingZoomedIn) => on_clear_is_checking_zoomed_in as extern fn(&Object, Sel),
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize as extern fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen as extern fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen as extern fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen as extern fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen as extern fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
}))
}
app.get_window("main").unwrap().set_transparent_titlebar();
let window_handle = app.get_window("main").unwrap();
update_window_theme(&window_handle, HexColor::WHITE);
// Control window theme based on app update_window
app.listen_global("hopp-bg-changed", move |ev| {
let payload = serde_json::from_str::<&str>(ev.payload().unwrap())
.unwrap()
.trim();
let color = HexColor::parse_rgb(payload).unwrap();
update_window_theme(&window_handle, color);
});
}

View file

@ -1,80 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "macos")]
#[macro_use]
extern crate cocoa;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
#[cfg(target_os = "macos")]
mod mac;
#[cfg(target_os = "windows")]
mod win;
mod interceptor;
mod interop;
use tauri::Manager;
fn main() {
tauri_plugin_deep_link::prepare("io.hoppscotch.desktop");
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
interop::startup::init::interop_startup_init
])
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
// NOTE:
// The app (window labeled "main") manages its visible state via `interop_startup_init`.
// See `tauri.conf.json`:
// ```json
// {
// "label": "main",
// "title": "Hoppscotch",
// ...
// ...
// "visible": false, // This is the important part.
// ...
// ...
// }
// ```
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.build(),
)
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(interceptor::init())
.setup(|app| {
if cfg!(target_os = "macos") {
#[cfg(target_os = "macos")]
use mac::window::setup_mac_window;
#[cfg(target_os = "macos")]
setup_mac_window(app);
} else if cfg!(target_os = "windows") {
#[cfg(target_os = "windows")]
use win::window::setup_win_window;
#[cfg(target_os = "windows")]
setup_win_window(app);
}
let handle = app.handle();
tauri_plugin_deep_link::register("hoppscotch", move |request| {
println!("{:?}", request);
handle.emit_all("scheme-request-received", request).unwrap();
})
.unwrap();
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1 +0,0 @@
pub mod window;

View file

@ -1,95 +0,0 @@
use hex_color::HexColor;
use tauri::App;
use tauri::Manager;
use std::mem::transmute;
use std::{ptr, ffi::c_void, mem::size_of};
use windows::Win32::UI::Controls::{WTA_NONCLIENT, WTNCA_NODRAWICON, WTNCA_NOSYSMENU, WTNCA_NOMIRRORHELP};
use windows::Win32::UI::Controls::SetWindowThemeAttribute;
use windows::Win32::UI::Controls::WTNCA_NODRAWCAPTION;
use windows::Win32::Graphics::Dwm::DWMWA_CAPTION_COLOR;
use windows::Win32::Foundation::COLORREF;
use windows::Win32::Foundation::BOOL;
use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute;
use windows::Win32::Foundation::HWND;
use windows::Win32::Graphics::Dwm::{DWMWA_USE_IMMERSIVE_DARK_MODE};
use winver::WindowsVersion;
fn hex_color_to_colorref(color: HexColor) -> COLORREF {
// TODO: Remove this unsafe, This operation doesn't need to be unsafe!
unsafe {
COLORREF(transmute::<[u8; 4], u32>([color.r, color.g, color.b, 0]))
}
}
struct WinThemeAttribute {
flag: u32,
mask: u32
}
#[cfg(target_os = "windows")]
fn update_bg_color(hwnd: &HWND, bg_color: HexColor) {
let use_dark_mode = BOOL::from(true);
let final_color = hex_color_to_colorref(bg_color);
unsafe {
DwmSetWindowAttribute(
HWND(hwnd.0),
DWMWA_USE_IMMERSIVE_DARK_MODE,
ptr::addr_of!(use_dark_mode) as *const c_void,
size_of::<BOOL>().try_into().unwrap()
).unwrap();
}
let version = WindowsVersion::detect().unwrap();
if version >= WindowsVersion::new(10, 0, 22000) {
unsafe {
DwmSetWindowAttribute(
HWND(hwnd.0),
DWMWA_CAPTION_COLOR,
ptr::addr_of!(final_color) as *const c_void,
size_of::<COLORREF>().try_into().unwrap()
).unwrap();
}
}
let flags = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON;
let mask = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON | WTNCA_NOSYSMENU | WTNCA_NOMIRRORHELP;
let options = WinThemeAttribute { flag: flags, mask };
unsafe {
SetWindowThemeAttribute(
HWND(hwnd.0),
WTA_NONCLIENT,
ptr::addr_of!(options) as *const c_void,
size_of::<WinThemeAttribute>().try_into().unwrap()
).unwrap();
}
}
#[cfg(target_os = "windows")]
pub fn setup_win_window(app: &mut App) {
let window = app.get_window("main").unwrap();
let win_handle = window.hwnd().unwrap();
let win_clone = win_handle.clone();
app.listen_global("hopp-bg-changed", move |ev| {
let payload = serde_json::from_str::<&str>(ev.payload().unwrap())
.unwrap()
.trim();
let color = HexColor::parse_rgb(payload).unwrap();
update_bg_color(&HWND(win_clone.0), color);
});
update_bg_color(&HWND(win_handle.0), HexColor::rgb(23, 23, 23));
}

View file

@ -1,70 +0,0 @@
{
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "Hoppscotch",
"version": "25.1.1"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"os": {
"all": true
},
"fs": {
"writeFile": true
},
"dialog": {
"save": true
},
"http": {
"all": true,
"request": true,
"scope": ["http://*", "https://*", "wss://*"]
},
"window": {
"startDragging": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "io.hoppscotch.desktop",
"targets": "all"
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"label": "main",
"title": "Hoppscotch",
"visible": false,
"fullscreen": false,
"resizable": true,
"width": 800,
"height": 600,
"fileDropEnabled": false
}
]
}
}

View file

@ -1,5 +0,0 @@
mutation ClearGlobalEnvironments($id: ID!) {
clearGlobalEnvironments(id: $id) {
id
}
}

View file

@ -1,14 +0,0 @@
mutation CreateGQLChildUserCollection(
$title: String!
$parentUserCollectionID: ID!
$data: String
) {
createGQLChildUserCollection(
title: $title
parentUserCollectionID: $parentUserCollectionID
data: $data
) {
id
data
}
}

View file

@ -1,6 +0,0 @@
mutation CreateGQLRootUserCollection($title: String!, $data: String) {
createGQLRootUserCollection(title: $title, data: $data) {
id
data
}
}

View file

@ -1,13 +0,0 @@
mutation CreateGQLUserRequest(
$title: String!
$request: String!
$collectionID: ID!
) {
createGQLUserRequest(
title: $title
request: $request
collectionID: $collectionID
) {
id
}
}

View file

@ -1,14 +0,0 @@
mutation CreateRESTChildUserCollection(
$title: String!
$parentUserCollectionID: ID!
$data: String
) {
createRESTChildUserCollection(
title: $title
parentUserCollectionID: $parentUserCollectionID
data: $data
) {
id
data
}
}

View file

@ -1,6 +0,0 @@
mutation CreateRESTRootUserCollection($title: String!, $data: String) {
createRESTRootUserCollection(title: $title, data: $data) {
id
data
}
}

View file

@ -1,13 +0,0 @@
mutation CreateRESTUserRequest(
$collectionID: ID!
$title: String!
$request: String!
) {
createRESTUserRequest(
collectionID: $collectionID
title: $title
request: $request
) {
id
}
}

View file

@ -1,9 +0,0 @@
mutation CreateUserEnvironment($name: String!, $variables: String!) {
createUserEnvironment(name: $name, variables: $variables) {
id
userUid
name
variables
isGlobal
}
}

View file

@ -1,5 +0,0 @@
mutation CreateUserGlobalEnvironment($variables: String!) {
createUserGlobalEnvironment(variables: $variables) {
id
}
}

View file

@ -1,13 +0,0 @@
mutation CreateUserHistory(
$reqData: String!
$resMetadata: String!
$reqType: ReqType!
) {
createUserHistory(
reqData: $reqData
resMetadata: $resMetadata
reqType: $reqType
) {
id
}
}

View file

@ -1,5 +0,0 @@
mutation CreateUserSettings($properties: String!) {
createUserSettings(properties: $properties) {
id
}
}

View file

@ -1,6 +0,0 @@
mutation DeleteAllUserHistory($reqType: ReqType!) {
deleteAllUserHistory(reqType: $reqType) {
count
reqType
}
}

View file

@ -1,3 +0,0 @@
mutation DeleteUserCollection($userCollectionID: ID!) {
deleteUserCollection(userCollectionID: $userCollectionID)
}

View file

@ -1,3 +0,0 @@
mutation DeleteUserEnvironment($id: ID!) {
deleteUserEnvironment(id: $id)
}

View file

@ -1,3 +0,0 @@
mutation DeleteUserRequest($requestID: ID!) {
deleteUserRequest(id: $requestID)
}

View file

@ -1,3 +0,0 @@
mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) {
duplicateUserCollection(collectionID: $collectionID, reqType: $reqType)
}

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