448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
import { Service } from "dioc"
|
|
import { BehaviorSubject, Observable } from "rxjs"
|
|
import { computed } from "vue"
|
|
import { LazyStore } from "@tauri-apps/plugin-store"
|
|
import { download, load, clear, remove } from "@hoppscotch/plugin-appload"
|
|
import { useToast } from "~/composables/toast"
|
|
|
|
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()
|
|
|
|
override async onServiceInit(): Promise<void> {
|
|
this.store = new LazyStore(STORE_PATH)
|
|
await this.store.init()
|
|
await this.loadRecentInstances()
|
|
|
|
if (this.inVendoredEnvironment()) {
|
|
const instance: VendoredInstance = {
|
|
type: "vendored",
|
|
displayName: "Hoppscotch",
|
|
version: "25.5.1",
|
|
}
|
|
|
|
this.state$.next({
|
|
status: "connected",
|
|
instance,
|
|
})
|
|
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: "Vendored",
|
|
})
|
|
this.emit(this.state$.value)
|
|
|
|
try {
|
|
const instance: VendoredInstance = {
|
|
type: "vendored",
|
|
displayName: "Hoppscotch",
|
|
version: "25.5.1",
|
|
}
|
|
|
|
this.state$.next({
|
|
status: "connected",
|
|
instance,
|
|
})
|
|
this.emit(this.state$.value)
|
|
|
|
await this.saveCurrentState()
|
|
|
|
this.toast.success("Connecting to Vendored")
|
|
|
|
const loadResponse = await load({
|
|
bundleName: "Hoppscotch",
|
|
window: { title: "Hoppscotch" },
|
|
})
|
|
|
|
if (!loadResponse.success) {
|
|
throw new Error("Failed to load vendored bundle")
|
|
}
|
|
|
|
this.toast.success("Connected to Vendored")
|
|
return true
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error)
|
|
this.state$.next({
|
|
status: "error",
|
|
target: "Vendored",
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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, go back to idle state
|
|
if (this.isCurrentlyConnectedTo(serverUrl)) {
|
|
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 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 === "vendored"
|
|
}
|
|
|
|
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 {
|
|
const re = withSubpath.toString().replace(/\/$/, "")
|
|
return re
|
|
} catch {
|
|
if (!urlObj.port) {
|
|
urlObj.port = "3200"
|
|
}
|
|
const re = urlObj.toString().replace(/\/$/, "")
|
|
return re
|
|
}
|
|
}
|
|
|
|
const re = urlObj.toString().replace(/\/$/, "")
|
|
return re
|
|
} 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)
|
|
}
|
|
}
|
|
}
|