feat(mobile): vérification automatique de mise à jour de version
Réutilise l'API Releases de Forgejo (publique, sans authentification) comme source de vérité pour "quelle est la dernière version" — zéro nouvel endpoint backend : useAppUpdateCheck compare App.getInfo().version (@capacitor/app, nouvelle dépendance) au tag_name de GET .../releases/latest, et pointe "Mettre à jour" vers l'URL stable de téléchargement (issue #65). Comparaison de version en logique pure (version-compare.ts), séparée du hook effectful — testée isolément via un test de composant Cypress (version-compare.cy.tsx, même patron que highlight-tech-steps.cy.tsx : aucun montage, juste describe/it/expect sur des fonctions pures). Natif uniquement (Capacitor.isNativePlatform()) : @capacitor/app et @capacitor/preferences restent en import dynamique dans le hook, jamais dans le bundle web (même patron que capacitor-token-store.ts) — vérifié dans le build (deux petits chunks séparés, pas dans le bundle principal). Vérification au démarrage à froid + à la reprise depuis l'arrière-plan (appStateChange), avec un throttle de 6h et une mémorisation de la version déjà rejetée dans @capacitor/preferences pour ne jamais re-harceler sur la même version. Bannière discrète (pas une Dialog modale), bouton "Mettre à jour" ouvrant le lien de téléchargement dans le navigateur système plutôt qu'un flux PackageInstaller in-app. Vérifié : pnpm lint, pnpm --filter web build, le test de composant (9/9), et layout/smoke/planning-page e2e (18/18, la bannière étant un no-op sur le web ne devait rien casser — confirmé). Closes #66 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
dcfb86d6c9
commit
5d4d6be2dc
9 changed files with 340 additions and 0 deletions
51
apps/web/cypress/component/version-compare.cy.tsx
Normal file
51
apps/web/cypress/component/version-compare.cy.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { isNewerVersion, parseVersion } from "../../src/features/app-update/version-compare";
|
||||||
|
|
||||||
|
// Pure logic, no DOM/mount needed — same posture as highlight-tech-steps.cy.tsx:
|
||||||
|
// reuses the component-test runner (Cypress's Mocha/Chai) purely for `expect`.
|
||||||
|
// `.cy.tsx` (not `.cy.ts`) only because that's what cypress.config.ts's
|
||||||
|
// component `specPattern` looks for.
|
||||||
|
|
||||||
|
describe("parseVersion", () => {
|
||||||
|
it("parses a v-prefixed tag into its three integer components", () => {
|
||||||
|
expect(parseVersion("v1.4.2")).to.deep.equal({ major: 1, minor: 4, patch: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also accepts a bare X.Y.Z string (no leading v)", () => {
|
||||||
|
expect(parseVersion("1.4.2")).to.deep.equal({ major: 1, minor: 4, patch: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for anything that doesn't match exactly", () => {
|
||||||
|
expect(parseVersion("")).to.be.null;
|
||||||
|
expect(parseVersion("not-a-version")).to.be.null;
|
||||||
|
expect(parseVersion("v1.4")).to.be.null;
|
||||||
|
expect(parseVersion("v1.4.2-beta")).to.be.null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isNewerVersion", () => {
|
||||||
|
it("is true when the major version is higher", () => {
|
||||||
|
expect(isNewerVersion("1.4.2", "v2.0.0")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when the minor version is higher, major equal", () => {
|
||||||
|
expect(isNewerVersion("1.4.2", "v1.5.0")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when only the patch version is higher", () => {
|
||||||
|
expect(isNewerVersion("1.4.2", "v1.4.3")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for the exact same version", () => {
|
||||||
|
expect(isNewerVersion("1.4.2", "v1.4.2")).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the candidate is actually older", () => {
|
||||||
|
expect(isNewerVersion("1.4.2", "v1.4.1")).to.be.false;
|
||||||
|
expect(isNewerVersion("2.0.0", "v1.9.9")).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed (false) when either side doesn't parse", () => {
|
||||||
|
expect(isNewerVersion("1.0", "v2.0.0")).to.be.false;
|
||||||
|
expect(isNewerVersion("1.0.0", "not-a-tag")).to.be.false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@batch-cooking/date-tools": "workspace:*",
|
"@batch-cooking/date-tools": "workspace:*",
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@capacitor/android": "^8.5.1",
|
"@capacitor/android": "^8.5.1",
|
||||||
|
"@capacitor/app": "^8.1.1",
|
||||||
"@capacitor/core": "^8.5.1",
|
"@capacitor/core": "^8.5.1",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
|
||||||
45
apps/web/src/features/app-update/UpdateBanner.tsx
Normal file
45
apps/web/src/features/app-update/UpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./update-banner.scss";
|
||||||
|
import { useAppUpdateCheck } from "./useAppUpdateCheck";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discreet, dismissible notice shown when a newer Android build has been
|
||||||
|
* tagged than the one installed (see `useAppUpdateCheck`'s own doc comment
|
||||||
|
* for the full mechanism — native only, `null` on the web, so this renders
|
||||||
|
* nothing there). A plain non-modal banner, not `Dialog.tsx`'s native
|
||||||
|
* `<dialog>`: a version check is informative, never something that should
|
||||||
|
* block the rest of the page.
|
||||||
|
*
|
||||||
|
* "Mettre à jour" opens the download link externally (the system browser
|
||||||
|
* handles the download + Android's own install prompt) rather than an
|
||||||
|
* in-app `PackageInstaller` flow — no `REQUEST_INSTALL_PACKAGES` permission,
|
||||||
|
* no extra native code, for a marginal UX gain (see issue #66's own
|
||||||
|
* hors-scope).
|
||||||
|
*/
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { availableVersion, dismiss } = useAppUpdateCheck();
|
||||||
|
|
||||||
|
if (!availableVersion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-banner" role="status">
|
||||||
|
<span className="update-banner__text">
|
||||||
|
{t("appUpdate.banner.text", { version: availableVersion })}
|
||||||
|
</span>
|
||||||
|
<div className="update-banner__actions">
|
||||||
|
<a
|
||||||
|
className="update-banner__update"
|
||||||
|
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("appUpdate.banner.update")}
|
||||||
|
</a>
|
||||||
|
<button type="button" className="update-banner__dismiss" onClick={dismiss}>
|
||||||
|
{t("appUpdate.banner.dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/web/src/features/app-update/update-banner.scss
Normal file
50
apps/web/src/features/app-update/update-banner.scss
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Styles for UpdateBanner — a discreet, dismissible strip above the routed
|
||||||
|
// page content (see AppLayout.tsx). Uses --color-warning the same way
|
||||||
|
// source-item-preview__hint does elsewhere in the app: "worth noticing",
|
||||||
|
// not an error.
|
||||||
|
.update-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||||
|
border: 1px solid var(--color-warning);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__update {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dismiss {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
apps/web/src/features/app-update/useAppUpdateCheck.ts
Normal file
119
apps/web/src/features/app-update/useAppUpdateCheck.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { isNewerVersion } from "./version-compare";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgejo's own Releases API for this repo — public, unauthenticated (the
|
||||||
|
* same fact `local-verification-while-ci-disabled` already leans on for
|
||||||
|
* CI-status checks without a token). Zero new backend endpoint needed: the
|
||||||
|
* tag of the latest *tagged* release is exactly "the latest version" this
|
||||||
|
* check needs, and it's already publicly readable.
|
||||||
|
*/
|
||||||
|
const LATEST_RELEASE_URL =
|
||||||
|
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
|
||||||
|
|
||||||
|
/** How often a background check is allowed to actually hit the network — cold start and every resume-from-background otherwise re-checks needlessly. */
|
||||||
|
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||||
|
|
||||||
|
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
|
||||||
|
const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects whether a newer version of the Android app has been tagged than
|
||||||
|
* the one currently installed, and offers a way to dismiss the notice for
|
||||||
|
* that specific version.
|
||||||
|
*
|
||||||
|
* **Native only** — on the web, "the app" is just whatever the server is
|
||||||
|
* currently serving; there is no "installed version" to compare against,
|
||||||
|
* and reloading the page always gets the latest build. `Capacitor`/
|
||||||
|
* `@capacitor/app`/`@capacitor/preferences` stay dynamically imported so
|
||||||
|
* none of them enter the web bundle (same pattern as `main.tsx`'s own
|
||||||
|
* `capacitor-token-store` bootstrap) — this hook is safe to call
|
||||||
|
* unconditionally from `AppLayout`, which renders on both platforms.
|
||||||
|
*
|
||||||
|
* No Play Store integration exists (this app isn't distributed there) —
|
||||||
|
* Google's In-App Update API has no purchase here at all, so this is a
|
||||||
|
* from-scratch check, not a wrapper around it.
|
||||||
|
*
|
||||||
|
* Fails silently on any network/parsing error — a background version check
|
||||||
|
* must never surface an error banner or block anything, same posture as
|
||||||
|
* `ThemeContext`'s own preferences fetch.
|
||||||
|
*/
|
||||||
|
export function useAppUpdateCheck(): {
|
||||||
|
/** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */
|
||||||
|
availableVersion: string | null;
|
||||||
|
/** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */
|
||||||
|
dismiss: () => void;
|
||||||
|
} {
|
||||||
|
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function checkForUpdate() {
|
||||||
|
try {
|
||||||
|
const [{ App }, { Preferences }] = await Promise.all([
|
||||||
|
import("@capacitor/app"),
|
||||||
|
import("@capacitor/preferences"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
|
||||||
|
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
|
||||||
|
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
|
||||||
|
|
||||||
|
const [{ version: installedVersion }, response] = await Promise.all([
|
||||||
|
App.getInfo(),
|
||||||
|
fetch(LATEST_RELEASE_URL),
|
||||||
|
]);
|
||||||
|
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const release = (await response.json()) as { tag_name?: string };
|
||||||
|
const latestTag = release.tag_name;
|
||||||
|
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return;
|
||||||
|
|
||||||
|
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });
|
||||||
|
if (dismissedVersion === latestTag) return;
|
||||||
|
|
||||||
|
if (!cancelled) setAvailableVersion(latestTag);
|
||||||
|
} catch {
|
||||||
|
// Silent — see this hook's own doc comment. Nothing to log to
|
||||||
|
// (apps/web has no client-side LoggerService equivalent yet), and a
|
||||||
|
// failed version check is never actionable for the visitor anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkForUpdate();
|
||||||
|
|
||||||
|
// Re-check whenever the app comes back to the foreground — the listener
|
||||||
|
// itself is cheap to keep registered, `checkForUpdate`'s own throttle
|
||||||
|
// above is what actually keeps this from hitting the network every time.
|
||||||
|
let removeListener: (() => void) | undefined;
|
||||||
|
void import("@capacitor/app").then(({ App }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
App.addListener("appStateChange", ({ isActive }) => {
|
||||||
|
if (isActive) void checkForUpdate();
|
||||||
|
}).then((handle) => {
|
||||||
|
removeListener = () => void handle.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
removeListener?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (!availableVersion) return;
|
||||||
|
const dismissed = availableVersion;
|
||||||
|
setAvailableVersion(null);
|
||||||
|
void import("@capacitor/preferences").then(({ Preferences }) =>
|
||||||
|
Preferences.set({ key: DISMISSED_VERSION_KEY, value: dismissed }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { availableVersion, dismiss };
|
||||||
|
}
|
||||||
51
apps/web/src/features/app-update/version-compare.ts
Normal file
51
apps/web/src/features/app-update/version-compare.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Pure version-comparison logic for the mobile update check (issue #66) —
|
||||||
|
* no Capacitor/network/DOM dependency, kept separate from
|
||||||
|
* `useAppUpdateCheck.ts` so it's testable in isolation (same
|
||||||
|
* pure-logic/effectful-hook split this repo uses everywhere else, e.g.
|
||||||
|
* `ingredient-matcher.ts`'s `matchIngredientName` vs `loadIngredientCatalog`).
|
||||||
|
*
|
||||||
|
* Release tags in this repo are always `vX.Y.Z` (see `.github/workflows/release.yml`'s
|
||||||
|
* `on: push: tags: ["v*.*.*"]`) — a hand-rolled comparison for exactly that
|
||||||
|
* shape is simpler than pulling in a semver library for three integers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One `vX.Y.Z` tag parsed into its three integer components. */
|
||||||
|
export interface ParsedVersion {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
patch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a `vX.Y.Z` (or bare `X.Y.Z`) string into its components — `null`
|
||||||
|
* if it doesn't match that exact shape (a malformed tag, an empty string, a
|
||||||
|
* pre-release suffix this app doesn't publish). Callers treat `null` as
|
||||||
|
* "can't compare, assume no update" rather than throwing — a version check
|
||||||
|
* is never allowed to be the reason the app breaks.
|
||||||
|
*/
|
||||||
|
export function parseVersion(raw: string): ParsedVersion | null {
|
||||||
|
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(raw.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const [, major, minor, patch] = match;
|
||||||
|
return { major: Number(major), minor: Number(minor), patch: Number(patch) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` when `latest` is a genuinely newer version than `current` —
|
||||||
|
* `false` for equal, older, or unparseable input on either side (fails
|
||||||
|
* closed: "don't nag" is always the safe default for a background check).
|
||||||
|
*/
|
||||||
|
export function isNewerVersion(current: string, latest: string): boolean {
|
||||||
|
const currentParsed = parseVersion(current);
|
||||||
|
const latestParsed = parseVersion(latest);
|
||||||
|
if (!currentParsed || !latestParsed) return false;
|
||||||
|
|
||||||
|
if (latestParsed.major !== currentParsed.major) {
|
||||||
|
return latestParsed.major > currentParsed.major;
|
||||||
|
}
|
||||||
|
if (latestParsed.minor !== currentParsed.minor) {
|
||||||
|
return latestParsed.minor > currentParsed.minor;
|
||||||
|
}
|
||||||
|
return latestParsed.patch > currentParsed.patch;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { UpdateBanner } from "../features/app-update/UpdateBanner";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
import "./AppLayout.scss";
|
import "./AppLayout.scss";
|
||||||
import {
|
import {
|
||||||
|
|
@ -124,6 +125,9 @@ export function AppLayout() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="app-content">
|
<main className="app-content">
|
||||||
|
{/* Renders nothing on the web build (native-only check) or once no
|
||||||
|
newer version is known — see UpdateBanner's own doc comment. */}
|
||||||
|
<UpdateBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1372,5 +1372,12 @@
|
||||||
"title": "Revoir un tutoriel",
|
"title": "Revoir un tutoriel",
|
||||||
"button": "Revoir"
|
"button": "Revoir"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"appUpdate": {
|
||||||
|
"banner": {
|
||||||
|
"text": "Une nouvelle version ({{version}}) est disponible.",
|
||||||
|
"update": "Mettre à jour",
|
||||||
|
"dismiss": "Plus tard"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ importers:
|
||||||
'@capacitor/android':
|
'@capacitor/android':
|
||||||
specifier: ^8.5.1
|
specifier: ^8.5.1
|
||||||
version: 8.5.1(@capacitor/core@8.5.1)
|
version: 8.5.1(@capacitor/core@8.5.1)
|
||||||
|
'@capacitor/app':
|
||||||
|
specifier: ^8.1.1
|
||||||
|
version: 8.1.1(@capacitor/core@8.5.1)
|
||||||
'@capacitor/core':
|
'@capacitor/core':
|
||||||
specifier: ^8.5.1
|
specifier: ^8.5.1
|
||||||
version: 8.5.1
|
version: 8.5.1
|
||||||
|
|
@ -441,6 +444,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@capacitor/core': ^8.5.0
|
'@capacitor/core': ^8.5.0
|
||||||
|
|
||||||
|
'@capacitor/app@8.1.1':
|
||||||
|
resolution: {integrity: sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==, tarball: https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
'@capacitor/core': '>=8.0.0'
|
||||||
|
|
||||||
'@capacitor/cli@8.5.1':
|
'@capacitor/cli@8.5.1':
|
||||||
resolution: {integrity: sha512-LKQFSyLg1QbTN6CGDoFqeRtAlj8C3c9OymP8OuhwlmkbSQwh9i1HQjwUCaNyVem2XkY9rv0v2UOfVlgoxH5pAw==, tarball: https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.1.tgz}
|
resolution: {integrity: sha512-LKQFSyLg1QbTN6CGDoFqeRtAlj8C3c9OymP8OuhwlmkbSQwh9i1HQjwUCaNyVem2XkY9rv0v2UOfVlgoxH5pAw==, tarball: https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.1.tgz}
|
||||||
engines: {node: '>=22.0.0'}
|
engines: {node: '>=22.0.0'}
|
||||||
|
|
@ -4446,6 +4454,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@capacitor/core': 8.5.1
|
'@capacitor/core': 8.5.1
|
||||||
|
|
||||||
|
'@capacitor/app@8.1.1(@capacitor/core@8.5.1)':
|
||||||
|
dependencies:
|
||||||
|
'@capacitor/core': 8.5.1
|
||||||
|
|
||||||
'@capacitor/cli@8.5.1':
|
'@capacitor/cli@8.5.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ionic/cli-framework-output': 2.2.8
|
'@ionic/cli-framework-output': 2.2.8
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue