Update checker
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"text/template"
|
"text/template"
|
||||||
@@ -275,3 +278,87 @@ func NewConfig() *Config {
|
|||||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configHashData is a subset of Config fields used for computing the config hash.
|
||||||
|
// It excludes sensitive fields (keys, passwords, tokens) and runtime-only fields.
|
||||||
|
type configHashData struct {
|
||||||
|
BaseURL string
|
||||||
|
ListenHTTP string
|
||||||
|
ListenHTTPS string
|
||||||
|
ListenUnix string
|
||||||
|
CacheDuration time.Duration
|
||||||
|
AttachmentTotalSizeLimit int64
|
||||||
|
AttachmentFileSizeLimit int64
|
||||||
|
AttachmentExpiryDuration time.Duration
|
||||||
|
KeepaliveInterval time.Duration
|
||||||
|
ManagerInterval time.Duration
|
||||||
|
DisallowedTopics []string
|
||||||
|
WebRoot string
|
||||||
|
MessageDelayMin time.Duration
|
||||||
|
MessageDelayMax time.Duration
|
||||||
|
MessageSizeLimit int
|
||||||
|
TotalTopicLimit int
|
||||||
|
VisitorSubscriptionLimit int
|
||||||
|
VisitorAttachmentTotalSizeLimit int64
|
||||||
|
VisitorAttachmentDailyBandwidthLimit int64
|
||||||
|
VisitorRequestLimitBurst int
|
||||||
|
VisitorRequestLimitReplenish time.Duration
|
||||||
|
VisitorMessageDailyLimit int
|
||||||
|
VisitorEmailLimitBurst int
|
||||||
|
VisitorEmailLimitReplenish time.Duration
|
||||||
|
EnableSignup bool
|
||||||
|
EnableLogin bool
|
||||||
|
RequireLogin bool
|
||||||
|
EnableReservations bool
|
||||||
|
EnableMetrics bool
|
||||||
|
EnablePayments bool
|
||||||
|
EnableCalls bool
|
||||||
|
EnableEmails bool
|
||||||
|
EnableWebPush bool
|
||||||
|
BillingContact string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash computes a SHA-256 hash of the configuration. This is used to detect
|
||||||
|
// configuration changes for the web app version check feature.
|
||||||
|
func (c *Config) Hash() string {
|
||||||
|
data := configHashData{
|
||||||
|
BaseURL: c.BaseURL,
|
||||||
|
ListenHTTP: c.ListenHTTP,
|
||||||
|
ListenHTTPS: c.ListenHTTPS,
|
||||||
|
ListenUnix: c.ListenUnix,
|
||||||
|
CacheDuration: c.CacheDuration,
|
||||||
|
AttachmentTotalSizeLimit: c.AttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSizeLimit: c.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: c.AttachmentExpiryDuration,
|
||||||
|
KeepaliveInterval: c.KeepaliveInterval,
|
||||||
|
ManagerInterval: c.ManagerInterval,
|
||||||
|
DisallowedTopics: c.DisallowedTopics,
|
||||||
|
WebRoot: c.WebRoot,
|
||||||
|
MessageDelayMin: c.MessageDelayMin,
|
||||||
|
MessageDelayMax: c.MessageDelayMax,
|
||||||
|
MessageSizeLimit: c.MessageSizeLimit,
|
||||||
|
TotalTopicLimit: c.TotalTopicLimit,
|
||||||
|
VisitorSubscriptionLimit: c.VisitorSubscriptionLimit,
|
||||||
|
VisitorAttachmentTotalSizeLimit: c.VisitorAttachmentTotalSizeLimit,
|
||||||
|
VisitorAttachmentDailyBandwidthLimit: c.VisitorAttachmentDailyBandwidthLimit,
|
||||||
|
VisitorRequestLimitBurst: c.VisitorRequestLimitBurst,
|
||||||
|
VisitorRequestLimitReplenish: c.VisitorRequestLimitReplenish,
|
||||||
|
VisitorMessageDailyLimit: c.VisitorMessageDailyLimit,
|
||||||
|
VisitorEmailLimitBurst: c.VisitorEmailLimitBurst,
|
||||||
|
VisitorEmailLimitReplenish: c.VisitorEmailLimitReplenish,
|
||||||
|
EnableSignup: c.EnableSignup,
|
||||||
|
EnableLogin: c.EnableLogin,
|
||||||
|
RequireLogin: c.RequireLogin,
|
||||||
|
EnableReservations: c.EnableReservations,
|
||||||
|
EnableMetrics: c.EnableMetrics,
|
||||||
|
EnablePayments: c.StripeSecretKey != "",
|
||||||
|
EnableCalls: c.TwilioAccount != "",
|
||||||
|
EnableEmails: c.SMTPSenderFrom != "",
|
||||||
|
EnableWebPush: c.WebPushPublicKey != "",
|
||||||
|
BillingContact: c.BillingContact,
|
||||||
|
Version: c.Version,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256(b))
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ var (
|
|||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
apiHealthPath = "/v1/health"
|
apiHealthPath = "/v1/health"
|
||||||
|
apiVersionPath = "/v1/version"
|
||||||
apiStatsPath = "/v1/stats"
|
apiStatsPath = "/v1/stats"
|
||||||
apiWebPushPath = "/v1/webpush"
|
apiWebPushPath = "/v1/webpush"
|
||||||
apiTiersPath = "/v1/tiers"
|
apiTiersPath = "/v1/tiers"
|
||||||
@@ -460,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||||
return s.handleHealth(w, r, v)
|
return s.handleHealth(w, r, v)
|
||||||
|
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {
|
||||||
|
return s.handleVersion(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||||
@@ -600,6 +603,14 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
|
|||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
|
response := &apiVersionResponse{
|
||||||
|
Version: s.config.Version,
|
||||||
|
ConfigHash: s.config.Hash(),
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
response := &apiConfigResponse{
|
response := &apiConfigResponse{
|
||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
@@ -615,6 +626,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
BillingContact: s.config.BillingContact,
|
BillingContact: s.config.BillingContact,
|
||||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
|
ConfigHash: s.config.Hash(),
|
||||||
}
|
}
|
||||||
b, err := json.MarshalIndent(response, "", " ")
|
b, err := json.MarshalIndent(response, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -317,6 +317,11 @@ type apiHealthResponse struct {
|
|||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiVersionResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
ConfigHash string `json:"config_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiStatsResponse struct {
|
type apiStatsResponse struct {
|
||||||
Messages int64 `json:"messages"`
|
Messages int64 `json:"messages"`
|
||||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||||
@@ -482,6 +487,7 @@ type apiConfigResponse struct {
|
|||||||
BillingContact string `json:"billing_contact"`
|
BillingContact string `json:"billing_contact"`
|
||||||
WebPushPublicKey string `json:"web_push_public_key"`
|
WebPushPublicKey string `json:"web_push_public_key"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
|
ConfigHash string `json:"config_hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountBillingPrices struct {
|
type apiAccountBillingPrices struct {
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ var config = {
|
|||||||
billing_contact: "",
|
billing_contact: "",
|
||||||
web_push_public_key: "",
|
web_push_public_key: "",
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
||||||
|
config_hash: "dev", // Placeholder for development; actual value is generated server-side
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"common_add": "Add",
|
"common_add": "Add",
|
||||||
"common_back": "Back",
|
"common_back": "Back",
|
||||||
"common_copy_to_clipboard": "Copy to clipboard",
|
"common_copy_to_clipboard": "Copy to clipboard",
|
||||||
|
"common_refresh": "Refresh",
|
||||||
|
"version_update_available": "New ntfy version available. Please refresh the page.",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
|
|||||||
93
web/src/app/VersionChecker.js
Normal file
93
web/src/app/VersionChecker.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* VersionChecker polls the /v1/version endpoint to detect server restarts
|
||||||
|
* or configuration changes, prompting users to refresh the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
class VersionChecker {
|
||||||
|
constructor() {
|
||||||
|
this.initialConfigHash = null;
|
||||||
|
this.listener = null;
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the version checker worker. It stores the initial config hash
|
||||||
|
* from the config.js and polls the server every 5 minutes.
|
||||||
|
*/
|
||||||
|
startWorker() {
|
||||||
|
// Store initial config hash from the config loaded at page load
|
||||||
|
this.initialConfigHash = window.config?.config_hash || null;
|
||||||
|
|
||||||
|
if (!this.initialConfigHash) {
|
||||||
|
console.log("[VersionChecker] No initial config_hash found, version checking disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[VersionChecker] Starting version checker with initial hash:", this.initialConfigHash);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
this.intervalId = setInterval(() => this.checkVersion(), CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the version checker worker.
|
||||||
|
*/
|
||||||
|
stopWorker() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
console.log("[VersionChecker] Stopped version checker");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener that will be called when a version change is detected.
|
||||||
|
* @param {function} listener - Callback function that receives no arguments
|
||||||
|
*/
|
||||||
|
registerListener(listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the listener.
|
||||||
|
*/
|
||||||
|
resetListener() {
|
||||||
|
this.listener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current version from the server and compares it with the initial config hash.
|
||||||
|
*/
|
||||||
|
async checkVersion() {
|
||||||
|
if (!this.initialConfigHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.config?.base_url || ""}/v1/version`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log("[VersionChecker] Failed to fetch version:", response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const currentHash = data.config_hash;
|
||||||
|
|
||||||
|
console.log("[VersionChecker] Checked version, initial:", this.initialConfigHash, "current:", currentHash);
|
||||||
|
|
||||||
|
if (currentHash && currentHash !== this.initialConfigHash) {
|
||||||
|
console.log("[VersionChecker] Config hash changed, notifying listener");
|
||||||
|
if (this.listener) {
|
||||||
|
this.listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VersionChecker] Error checking version:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionChecker = new VersionChecker();
|
||||||
|
export default versionChecker;
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
import { createContext, Suspense, useContext, useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
|
import {
|
||||||
|
Box,
|
||||||
|
Toolbar,
|
||||||
|
CssBaseline,
|
||||||
|
Backdrop,
|
||||||
|
CircularProgress,
|
||||||
|
useMediaQuery,
|
||||||
|
ThemeProvider,
|
||||||
|
createTheme,
|
||||||
|
Snackbar,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
} from "@mui/material";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -14,7 +26,13 @@ import userManager from "../app/UserManager";
|
|||||||
import { expandUrl, getKebabCaseLangStr } from "../app/utils";
|
import { expandUrl, getKebabCaseLangStr } from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
|
import {
|
||||||
|
useAccountListener,
|
||||||
|
useBackgroundProcesses,
|
||||||
|
useConnectionListeners,
|
||||||
|
useWebPushTopics,
|
||||||
|
useVersionChangeListener,
|
||||||
|
} from "./hooks";
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
@@ -100,10 +118,12 @@ const updateTitle = (newNotificationsCount) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { account, setAccount } = useContext(AccountContext);
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
|
const [versionChanged, setVersionChanged] = useState(false);
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const webPushTopics = useWebPushTopics();
|
const webPushTopics = useWebPushTopics();
|
||||||
@@ -115,9 +135,18 @@ const Layout = () => {
|
|||||||
(config.base_url === s.baseUrl && params.topic === s.topic)
|
(config.base_url === s.baseUrl && params.topic === s.topic)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleVersionChange = useCallback(() => {
|
||||||
|
setVersionChanged(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useConnectionListeners(account, subscriptions, users, webPushTopics);
|
useConnectionListeners(account, subscriptions, users, webPushTopics);
|
||||||
useAccountListener(setAccount);
|
useAccountListener(setAccount);
|
||||||
useBackgroundProcesses();
|
useBackgroundProcesses();
|
||||||
|
useVersionChangeListener(handleVersionChange);
|
||||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,6 +169,23 @@ const Layout = () => {
|
|||||||
/>
|
/>
|
||||||
</Main>
|
</Main>
|
||||||
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
||||||
|
<Snackbar
|
||||||
|
open={versionChanged}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
sx={{ bottom: { xs: 70, md: 24 } }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
variant="filled"
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={handleRefresh}>
|
||||||
|
{t("common_refresh")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("version_update_available")}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import poller from "../app/Poller";
|
|||||||
import pruner from "../app/Pruner";
|
import pruner from "../app/Pruner";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
|
import versionChecker from "../app/VersionChecker";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import prefs from "../app/Prefs";
|
import prefs from "../app/Prefs";
|
||||||
@@ -292,12 +293,14 @@ const startWorkers = () => {
|
|||||||
poller.startWorker();
|
poller.startWorker();
|
||||||
pruner.startWorker();
|
pruner.startWorker();
|
||||||
accountApi.startWorker();
|
accountApi.startWorker();
|
||||||
|
versionChecker.startWorker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopWorkers = () => {
|
const stopWorkers = () => {
|
||||||
poller.stopWorker();
|
poller.stopWorker();
|
||||||
pruner.stopWorker();
|
pruner.stopWorker();
|
||||||
accountApi.stopWorker();
|
accountApi.stopWorker();
|
||||||
|
versionChecker.stopWorker();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useBackgroundProcesses = () => {
|
export const useBackgroundProcesses = () => {
|
||||||
@@ -323,3 +326,15 @@ export const useAccountListener = (setAccount) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect version/config changes and call the provided callback when a change is detected.
|
||||||
|
*/
|
||||||
|
export const useVersionChangeListener = (onVersionChange) => {
|
||||||
|
useEffect(() => {
|
||||||
|
versionChecker.registerListener(onVersionChange);
|
||||||
|
return () => {
|
||||||
|
versionChecker.resetListener();
|
||||||
|
};
|
||||||
|
}, [onVersionChange]);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user