feat: extension identification improvements (#2332)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
@@ -8,11 +8,7 @@
|
||||
{{ t("settings.interceptor_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<SmartRadioGroup
|
||||
:radios="interceptors"
|
||||
:selected="interceptorSelection"
|
||||
@change="toggleSettingKey"
|
||||
/>
|
||||
<SmartRadioGroup v-model="interceptorSelection" :radios="interceptors" />
|
||||
<div
|
||||
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion"
|
||||
class="flex space-x-2"
|
||||
@@ -38,58 +34,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from "@nuxtjs/composition-api"
|
||||
import { KeysMatching } from "~/types/ts-utils"
|
||||
import {
|
||||
applySetting,
|
||||
SettingsType,
|
||||
toggleSetting,
|
||||
useSetting,
|
||||
} from "~/newstore/settings"
|
||||
import { hasExtensionInstalled } from "~/helpers/strategies/ExtensionStrategy"
|
||||
import { useI18n, usePolled } from "~/helpers/utils/composables"
|
||||
import { computed } from "@nuxtjs/composition-api"
|
||||
import { applySetting, toggleSetting, useSetting } from "~/newstore/settings"
|
||||
import { useI18n, useReadonlyStream } from "~/helpers/utils/composables"
|
||||
import { extensionStatus$ } from "~/newstore/HoppExtension"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
|
||||
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
|
||||
|
||||
const toggleSettingKey = <
|
||||
K extends KeysMatching<SettingsType | "BROWSER_ENABLED", boolean>
|
||||
>(
|
||||
key: K
|
||||
) => {
|
||||
interceptorSelection.value = key
|
||||
if (key === "EXTENSIONS_ENABLED") {
|
||||
applySetting("EXTENSIONS_ENABLED", true)
|
||||
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
|
||||
}
|
||||
if (key === "PROXY_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", true)
|
||||
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
|
||||
}
|
||||
if (key === "BROWSER_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", false)
|
||||
applySetting("EXTENSIONS_ENABLED", false)
|
||||
}
|
||||
}
|
||||
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
|
||||
|
||||
const extensionVersion = usePolled(5000, (stopPolling) => {
|
||||
const result = hasExtensionInstalled()
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
|
||||
const extensionVersion = computed(() => {
|
||||
return currentExtensionStatus.value === "available"
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
|
||||
: null
|
||||
|
||||
// We don't need to poll anymore after we get value
|
||||
if (result) stopPolling()
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const interceptors = computed(() => [
|
||||
{ value: "BROWSER_ENABLED", label: t("state.none") },
|
||||
{ value: "PROXY_ENABLED", label: t("settings.proxy") },
|
||||
{ value: "BROWSER_ENABLED" as const, label: t("state.none") },
|
||||
{ value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
|
||||
{
|
||||
value: "EXTENSIONS_ENABLED",
|
||||
value: "EXTENSIONS_ENABLED" as const,
|
||||
label:
|
||||
`${t("settings.extensions")}: ` +
|
||||
(extensionVersion.value !== null
|
||||
@@ -98,15 +65,27 @@ const interceptors = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const interceptorSelection = ref("")
|
||||
type InterceptorMode = typeof interceptors["value"][number]["value"]
|
||||
|
||||
watchEffect(() => {
|
||||
if (PROXY_ENABLED.value) {
|
||||
interceptorSelection.value = "PROXY_ENABLED"
|
||||
} else if (EXTENSIONS_ENABLED.value) {
|
||||
interceptorSelection.value = "EXTENSIONS_ENABLED"
|
||||
} else {
|
||||
interceptorSelection.value = "BROWSER_ENABLED"
|
||||
}
|
||||
const interceptorSelection = computed<InterceptorMode>({
|
||||
get() {
|
||||
if (PROXY_ENABLED.value) return "PROXY_ENABLED"
|
||||
if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
|
||||
return "BROWSER_ENABLED"
|
||||
},
|
||||
set(val) {
|
||||
if (val === "EXTENSIONS_ENABLED") {
|
||||
applySetting("EXTENSIONS_ENABLED", true)
|
||||
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
|
||||
}
|
||||
if (val === "PROXY_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", true)
|
||||
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
|
||||
}
|
||||
if (val === "BROWSER_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", false)
|
||||
applySetting("EXTENSIONS_ENABLED", false)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<template>
|
||||
<SmartItem
|
||||
:label="label"
|
||||
:icon="
|
||||
value === selected ? 'radio_button_checked' : 'radio_button_unchecked'
|
||||
"
|
||||
:active="value === selected"
|
||||
:icon="selected ? 'radio_button_checked' : 'radio_button_unchecked'"
|
||||
:active="selected"
|
||||
role="radio"
|
||||
:aria-checked="value === selected"
|
||||
@click.native="$emit('change', value)"
|
||||
:aria-checked="selected"
|
||||
@click.native="emit('change', value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "change", value: string): void
|
||||
}>()
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,18 +5,22 @@
|
||||
:key="`radio-${index}`"
|
||||
:value="radio.value"
|
||||
:label="radio.label"
|
||||
:selected="selected"
|
||||
@change="$emit('change', radio.value)"
|
||||
:selected="value === radio.value"
|
||||
@change="emit('input', radio.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "input", value: string): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
radios: Array<{
|
||||
value: string
|
||||
value: string // The key of the radio option
|
||||
label: string
|
||||
}>
|
||||
selected: string
|
||||
value: string // Should be a radio key given in the radios array
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
@@ -15,12 +16,42 @@ export const hasFirefoxExtensionInstalled = () =>
|
||||
hasExtensionInstalled() && browserIsFirefox()
|
||||
|
||||
export const cancelRunningExtensionRequest = () => {
|
||||
if (
|
||||
hasExtensionInstalled() &&
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest
|
||||
) {
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest()
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRunningRequest()
|
||||
}
|
||||
|
||||
export const defineSubscribableObject = <T extends object>(obj: T) => {
|
||||
const proxyObject = {
|
||||
...obj,
|
||||
_subscribers: {} as {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[key in keyof T]?: ((...args: any[]) => any)[]
|
||||
},
|
||||
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
|
||||
if (Array.isArray(this._subscribers[prop])) {
|
||||
this._subscribers[prop]?.push(func)
|
||||
} else {
|
||||
this._subscribers[prop] = [func]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type SubscribableProxyObject = typeof proxyObject
|
||||
|
||||
return new Proxy(proxyObject, {
|
||||
set(obj, prop, newVal) {
|
||||
obj[prop as keyof SubscribableProxyObject] = newVal
|
||||
|
||||
const currentSubscribers = obj._subscribers[prop as keyof T]
|
||||
|
||||
if (Array.isArray(currentSubscribers)) {
|
||||
for (const subscriber of currentSubscribers) {
|
||||
subscriber(newVal)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
|
||||
@@ -56,13 +87,20 @@ const extensionStrategy: NetworkStrategy = (req) =>
|
||||
|
||||
// Run the request
|
||||
TE.bind("response", ({ processedReq }) =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({
|
||||
...processedReq,
|
||||
wantsBinary: true,
|
||||
}) as Promise<NetworkResponse>,
|
||||
(err) => err as any
|
||||
pipe(
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__,
|
||||
O.fromNullable,
|
||||
TE.fromOption(() => "NO_PW_EXT_HOOK" as const),
|
||||
TE.chain((extensionHook) =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
extensionHook.sendRequest({
|
||||
...processedReq,
|
||||
wantsBinary: true,
|
||||
}),
|
||||
(err) => err as any
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
@@ -122,13 +122,6 @@ describe("cancelRunningExtensionRequest", () => {
|
||||
cancelRunningExtensionRequest()
|
||||
expect(cancelFunc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not cancel request if extension installed but function not present", () => {
|
||||
global.__POSTWOMAN_EXTENSION_HOOK__ = {}
|
||||
|
||||
cancelRunningExtensionRequest()
|
||||
expect(cancelFunc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extensionStrategy", () => {
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
useRouter,
|
||||
watch,
|
||||
ref,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
} from "@nuxtjs/composition-api"
|
||||
import { Splitpanes, Pane } from "splitpanes"
|
||||
import "splitpanes/dist/splitpanes.css"
|
||||
@@ -77,6 +79,12 @@ import { hookKeybindingsListener } from "~/helpers/keybindings"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { useSentry } from "~/helpers/sentry"
|
||||
import { useColorMode } from "~/helpers/utils/composables"
|
||||
import {
|
||||
changeExtensionStatus,
|
||||
ExtensionStatus,
|
||||
} from "~/newstore/HoppExtension"
|
||||
|
||||
import { defineSubscribableObject } from "~/helpers/strategies/ExtensionStrategy"
|
||||
|
||||
function appLayout() {
|
||||
const rightSidebar = useSetting("SIDEBAR")
|
||||
@@ -202,6 +210,62 @@ function defineJumpActions() {
|
||||
})
|
||||
}
|
||||
|
||||
function setupExtensionHooks() {
|
||||
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
onMounted(() => {
|
||||
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
|
||||
changeExtensionStatus(window.__HOPP_EXTENSION_STATUS_PROXY__.status)
|
||||
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
|
||||
"status",
|
||||
(status: ExtensionStatus) => changeExtensionStatus(status)
|
||||
)
|
||||
} else {
|
||||
const statusProxy = defineSubscribableObject({
|
||||
status: "waiting" as ExtensionStatus,
|
||||
})
|
||||
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
|
||||
statusProxy.subscribe("status", (status: ExtensionStatus) =>
|
||||
changeExtensionStatus(status)
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeping identifying extension backward compatible
|
||||
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
|
||||
* then we use a poll to find the version, this will get the version for 0.24 and any other version
|
||||
* of the extension, but will have a slight lag.
|
||||
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
|
||||
*/
|
||||
extensionPollIntervalId.value = setInterval(() => {
|
||||
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
|
||||
if (extensionPollIntervalId.value)
|
||||
clearInterval(extensionPollIntervalId.value)
|
||||
|
||||
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
|
||||
|
||||
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
|
||||
if (
|
||||
version.major === 0 &&
|
||||
version.minor <= 23 &&
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__
|
||||
) {
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup timer
|
||||
onBeforeUnmount(() => {
|
||||
if (extensionPollIntervalId.value) {
|
||||
clearInterval(extensionPollIntervalId.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { Splitpanes, Pane },
|
||||
setup() {
|
||||
@@ -229,6 +293,8 @@ export default defineComponent({
|
||||
showSupport.value = !showSupport.value
|
||||
})
|
||||
|
||||
setupExtensionHooks()
|
||||
|
||||
return {
|
||||
mdAndLarger,
|
||||
spacerClass,
|
||||
|
||||
40
packages/hoppscotch-app/newstore/HoppExtension.ts
Normal file
40
packages/hoppscotch-app/newstore/HoppExtension.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { distinctUntilChanged, pluck } from "rxjs"
|
||||
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
|
||||
|
||||
export type ExtensionStatus = "available" | "unknown-origin" | "waiting"
|
||||
|
||||
type InitialState = {
|
||||
extensionStatus: ExtensionStatus
|
||||
}
|
||||
|
||||
const initialState: InitialState = {
|
||||
extensionStatus: "waiting",
|
||||
}
|
||||
|
||||
const dispatchers = defineDispatchers({
|
||||
changeExtensionStatus(
|
||||
_,
|
||||
{ extensionStatus }: { extensionStatus: ExtensionStatus }
|
||||
) {
|
||||
return {
|
||||
extensionStatus,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const hoppExtensionStore = new DispatchingStore(
|
||||
initialState,
|
||||
dispatchers
|
||||
)
|
||||
|
||||
export const extensionStatus$ = hoppExtensionStore.subject$.pipe(
|
||||
pluck("extensionStatus"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export function changeExtensionStatus(extensionStatus: ExtensionStatus) {
|
||||
hoppExtensionStore.dispatch({
|
||||
dispatcher: "changeExtensionStatus",
|
||||
payload: { extensionStatus },
|
||||
})
|
||||
}
|
||||
@@ -241,14 +241,11 @@ import {
|
||||
useToast,
|
||||
useI18n,
|
||||
useColorMode,
|
||||
usePolled,
|
||||
useReadonlyStream,
|
||||
} from "~/helpers/utils/composables"
|
||||
import {
|
||||
hasExtensionInstalled,
|
||||
hasChromeExtensionInstalled,
|
||||
hasFirefoxExtensionInstalled,
|
||||
} from "~/helpers/strategies/ExtensionStrategy"
|
||||
|
||||
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
|
||||
import { extensionStatus$ } from "~/newstore/HoppExtension"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -263,30 +260,21 @@ const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
const ZEN_MODE = useSetting("ZEN_MODE")
|
||||
|
||||
const extensionVersion = usePolled(5000, (stopPolling) => {
|
||||
const result = hasExtensionInstalled()
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
|
||||
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
|
||||
|
||||
const extensionVersion = computed(() => {
|
||||
return currentExtensionStatus.value === "available"
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
|
||||
: null
|
||||
|
||||
// We don't need to poll anymore after we get value
|
||||
if (result) stopPolling()
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const hasChromeExtInstalled = usePolled(5000, (stopPolling) => {
|
||||
// If not Chrome, we don't need to worry about this value changing
|
||||
if (!browserIsChrome()) stopPolling()
|
||||
const hasChromeExtInstalled = computed(
|
||||
() => browserIsChrome() && currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
return hasChromeExtensionInstalled()
|
||||
})
|
||||
|
||||
const hasFirefoxExtInstalled = usePolled(5000, (stopPolling) => {
|
||||
// If not Chrome, we don't need to worry about this value changing
|
||||
if (!browserIsFirefox()) stopPolling()
|
||||
|
||||
return hasFirefoxExtensionInstalled()
|
||||
})
|
||||
const hasFirefoxExtInstalled = computed(
|
||||
() => browserIsFirefox() && currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
const clearIcon = ref("rotate-ccw")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import { NetworkResponse } from "~/helpers/network"
|
||||
import { ExtensionStatus } from "~/newstore/HoppExtension"
|
||||
|
||||
export interface PWExtensionHook {
|
||||
getVersion: () => { major: number; minor: number }
|
||||
@@ -8,3 +9,11 @@ export interface PWExtensionHook {
|
||||
) => Promise<NetworkResponse>
|
||||
cancelRunningRequest: () => void
|
||||
}
|
||||
|
||||
export type HoppExtensionStatusHook = {
|
||||
status: ExtensionStatus
|
||||
_subscribers: {
|
||||
status?: ((...args: any[]) => any)[] | undefined
|
||||
}
|
||||
subscribe(prop: "status", func: (...args: any[]) => any): void
|
||||
}
|
||||
|
||||
7
packages/hoppscotch-app/types/window.d.ts
vendored
7
packages/hoppscotch-app/types/window.d.ts
vendored
@@ -1,9 +1,8 @@
|
||||
import { PWExtensionHook } from "./pw-ext-hook"
|
||||
|
||||
export {}
|
||||
import { HoppExtensionStatusHook, PWExtensionHook } from "./pw-ext-hook"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__POSTWOMAN_EXTENSION_HOOK__: PWExtensionHook
|
||||
__POSTWOMAN_EXTENSION_HOOK__: PWExtensionHook | undefined
|
||||
__HOPP_EXTENSION_STATUS_PROXY__: HoppExtensionStatusHook | undefined
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user