diff --git a/.github/actions/validate-live-update/action.yml b/.github/actions/validate-live-update/action.yml new file mode 100644 index 0000000..598d1f8 --- /dev/null +++ b/.github/actions/validate-live-update/action.yml @@ -0,0 +1,26 @@ +name: Validate Capacitor Live Update +description: Validate that a tagged web bundle is compatible with the current native app. +inputs: + app-path: + description: Path to the Capacitor app from the repository root. + required: false + default: app + tag: + description: Release tag in vX.Y.Z or vX.Y.Z-N format. + required: true +outputs: + version: + description: Normalized release version without the v prefix. + value: ${{ steps.validate.outputs.version }} + build_number: + description: Shared Android and iOS native build number. + value: ${{ steps.validate.outputs.build_number }} + production_channel: + description: Versioned production channel for the native build. + value: ${{ steps.validate.outputs.production_channel }} +runs: + using: composite + steps: + - id: validate + shell: bash + run: node "$GITHUB_ACTION_PATH/validate-live-update.mjs" --app-path "${{ inputs.app-path }}" --tag "${{ inputs.tag }}" diff --git a/.github/actions/validate-live-update/validate-live-update.mjs b/.github/actions/validate-live-update/validate-live-update.mjs new file mode 100644 index 0000000..889a4f6 --- /dev/null +++ b/.github/actions/validate-live-update/validate-live-update.mjs @@ -0,0 +1,107 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); +} +const appPath = args.get('--app-path') ?? 'app'; +const repoAppPath = appPath.replace(/^\.\//, '').replace(/\/$/, ''); +const tag = args.get('--tag')?.replace(/^v/, ''); +if (!tag) throw new Error('A release tag is required.'); +process.chdir(appPath); + +if (execFileSync('git', ['rev-parse', '--is-shallow-repository'], { encoding: 'utf8' }).trim() === 'true') { + throw new Error('Live Update validation requires the complete Git history. Use actions/checkout with fetch-depth: 0.'); +} + +const match = /^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/.exec(tag); +if (!match) throw new Error(`Invalid live update tag: ${tag}`); + +const android = readFileSync('android/app/build.gradle', 'utf8'); +const ios = readFileSync('ios/App/App.xcodeproj/project.pbxproj', 'utf8'); +const androidVersion = /versionName\s+"(\d+)\.(\d+)\.(\d+)"/.exec(android); +const androidBuild = /versionCode\s+(\d+)/.exec(android)?.[1]; +const iosVersion = /MARKETING_VERSION = (\d+)\.(\d+)\.(\d+);/.exec(ios); +const iosBuild = /CURRENT_PROJECT_VERSION = (\d+);/.exec(ios)?.[1]; +if (!androidVersion || !androidBuild || !iosVersion || !iosBuild) throw new Error('Unable to read native versions.'); +if (androidVersion.slice(1).join('.') !== iosVersion.slice(1).join('.') || androidBuild !== iosBuild) { + throw new Error('Android and iOS native versions/build numbers must match.'); +} + +const [, major, minor, patch] = match; +if (major !== androidVersion[1] || minor !== androidVersion[2] || Number(patch) < Number(androidVersion[3])) { + throw new Error(`Tag v${tag} is not compatible with native ${androidVersion.slice(1).join('.')}.`); +} +const expectedBuildPrefix = Number(major) * 100 + Number(minor); +if (Math.floor(Number(androidBuild) / 10000) !== expectedBuildPrefix) { + throw new Error(`Native build number ${androidBuild} does not encode major/minor ${major}.${minor}.`); +} + +const tags = execFileSync('git', ['tag', '--merged', 'HEAD'], { encoding: 'utf8' }).trim().split('\n'); +const releaseOrder = ([, releaseMajor, releaseMinor, releasePatch, prerelease]) => [ + Number(releaseMajor), + Number(releaseMinor), + Number(releasePatch), + prerelease === undefined ? Number.MAX_SAFE_INTEGER : Number(prerelease), +]; +const compareRelease = (left, right) => { + const leftOrder = releaseOrder(left); + const rightOrder = releaseOrder(right); + for (let index = 0; index < leftOrder.length; index += 1) { + if (leftOrder[index] !== rightOrder[index]) return leftOrder[index] - rightOrder[index]; + } + return 0; +}; +const containsAppPackage = (candidate) => { + try { + execFileSync('git', ['cat-file', '-e', `${candidate}:${repoAppPath}/package.json`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; +const compatible = tags + .filter((candidate) => candidate !== `v${tag}`) + .map((candidate) => ({ candidate, match: /^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/.exec(candidate) })) + .filter(({ match: candidate }) => candidate?.[1] === major && candidate?.[2] === minor && compareRelease(candidate, match) < 0) + .filter(({ candidate }) => containsAppPackage(candidate)) + .sort((a, b) => compareRelease(b.match, a.match)); + +const previousTag = compatible[0]?.candidate; +if (previousTag) { + const nativeDependencies = (packageJson) => + Object.fromEntries( + Object.entries({ ...packageJson.dependencies, ...packageJson.devDependencies }).filter( + ([name]) => name.startsWith('@capacitor/') || name === '@capawesome/capacitor-live-update', + ), + ); + const previousPackage = JSON.parse(execFileSync('git', ['show', `${previousTag}:${repoAppPath}/package.json`], { encoding: 'utf8' })); + const currentPackage = JSON.parse(readFileSync('package.json', 'utf8')); + if (JSON.stringify(nativeDependencies(previousPackage)) !== JSON.stringify(nativeDependencies(currentPackage))) { + throw new Error('Capacitor plugin dependency changes require a store release.'); + } + const changed = execFileSync( + 'git', + [ + 'diff', + '--name-only', + previousTag, + 'HEAD', + '--', + `:(top)${repoAppPath}/android`, + `:(top)${repoAppPath}/ios`, + `:(top)${repoAppPath}/capacitor.config.ts`, + `:(top)${repoAppPath}/capacitor.config.json`, + ], + { encoding: 'utf8' }, + ).trim(); + if (changed) throw new Error(`Native changes require a store release:\n${changed}`); +} + +const output = process.env.GITHUB_OUTPUT; +if (output) { + const values = [`version=${tag}`, `build_number=${androidBuild}`, `production_channel=production-${androidBuild}`]; + await import('node:fs/promises').then(({ appendFile }) => appendFile(output, `${values.join('\n')}\n`)); +} +console.log(`Validated v${tag} for native ${androidVersion.slice(1).join('.')} (${androidBuild}).`); diff --git a/angular.json b/angular.json index 2fcc463..03559a7 100644 --- a/angular.json +++ b/angular.json @@ -255,6 +255,7 @@ "../printer/src/**/*.spec.ts", "../theme/src/**/*.spec.ts", "../review/src/**/*.spec.ts", + "../live-update/src/**/*.spec.ts", "../auth-firebase/src/**/*.spec.ts", "../auth-firebase/social/src/**/*.spec.ts" ] diff --git a/package-lock.json b/package-lock.json index a187d49..e139c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@capacitor/network": "^8.0.1", "@capacitor/preferences": "^8.0.1", "@capacitor/status-bar": "^8.0.2", + "@capawesome/capacitor-live-update": "^8.3.0", "@eslint/js": "^9.39.4", "@ionic/angular-toolkit": "^12.3.0", "@ionic/storage-angular": "^4.0.0", @@ -3684,6 +3685,26 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capawesome/capacitor-live-update": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capawesome/capacitor-live-update/-/capacitor-live-update-8.3.0.tgz", + "integrity": "sha512-t+zPrEJbzlbLSF5qsg3Tt7JnTCCkUmzGErGQoOYzkQcKkLblGEI5ekT5j8nsSYd0/W9zoccaYe6x4Ifoz7LwBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", diff --git a/package.json b/package.json index b7555d1..71587ec 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@capacitor/network": "^8.0.1", "@capacitor/preferences": "^8.0.1", "@capacitor/status-bar": "^8.0.2", + "@capawesome/capacitor-live-update": "^8.3.0", "@eslint/js": "^9.39.4", "@ionic/angular-toolkit": "^12.3.0", "@ionic/storage-angular": "^4.0.0", diff --git a/projects/kit/README.md b/projects/kit/README.md index 167fd6e..ad694d7 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -164,8 +164,8 @@ presentPopover( presentToast(options: ToastOptions): Promise // kit defaults: position='bottom', duration=2000, swipeGesture='vertical' -// A bottom toast with no explicit positionAnchor auto-anchors above a visible -// (so it clears the tabs); keyboard avoidance rides the native keyboard resize. +// A bottom toast with no explicit positionAnchor auto-anchors above a visible bottom +// (`slot="top"` bars are ignored) so it clears the tabs; keyboard avoidance rides the native keyboard resize. // caller options spread over the defaults — any field can be overridden alertClose(options: { header: string; message: string; subHeader?: string }): Promise @@ -552,12 +552,12 @@ await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: ch ### Firebase auth (`@rdlabo/ionic-angular-kit/auth-firebase`) -A secondary entry point so only apps that use it pull in `@angular/fire` and `firebase`. It exists to **isolate `@angular/fire`**: the SDK is touched in exactly one place — the DI provider — so apps import `KIT_FIREBASE_AUTH` and call these functions, never `@angular/fire` directly. That keeps the eventual `@angular/fire` → modular `firebase/auth` swap provider-local. +A secondary entry point so only apps that use it pull in `firebase` (declared as an optional peer dependency — install `firebase` in the app). It exists to **isolate the Firebase SDK**: `firebase/auth` is initialized in exactly one place — the DI provider — so apps import `KIT_FIREBASE_AUTH` and call these functions, never wiring `firebase/auth` themselves. The kit uses the vanilla modular `firebase/auth` SDK directly (no `@angular/fire`). **Design principle: the kit performs no UI.** Every function runs the Firebase operation and nothing else; loading overlays, prompts and error alerts are app side effects. The flow functions take the uniform lifecycle hooks `{ before, success, error, finally }` and, rather than throwing, resolve value flows to `null` and boolean flows to `false`, handing the raw error to the `error` hook so the app presents it from its own dictionary. For anything the functions don't express, drop down to `firebase/auth` directly. ```typescript -// app.config.ts — @angular/fire lives only here +// app.config.ts — Firebase is initialized only here provideKitFirebase({ firebaseConfig: environment.firebase }), provideKitFirebaseAnalytics(), ``` diff --git a/projects/kit/auth-firebase/social/src/kit-social.spec.ts b/projects/kit/auth-firebase/social/src/kit-social.spec.ts index bb3a635..7ffc330 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.spec.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.spec.ts @@ -15,7 +15,7 @@ const facebookLogout = vi.fn(); const facebookGetCurrentAccessToken = vi.fn(); const appleAuthorize = vi.fn(); -vi.mock('@angular/fire/auth', () => ({ +vi.mock('firebase/auth', () => ({ signInWithCredential: (...a: unknown[]) => signInWithCredential(...a), linkWithCredential: (...a: unknown[]) => linkWithCredential(...a), reauthenticateWithCredential: (...a: unknown[]) => reauthenticateWithCredential(...a), @@ -104,7 +104,7 @@ describe('kitFacebookLogin', () => { expect(h.finally).toHaveBeenCalledTimes(1); }); - it("uses the iOS OIDC nonce path (OAuthProvider) on native iOS", async () => { + it('uses the iOS OIDC nonce path (OAuthProvider) on native iOS', async () => { isNativePlatform.mockReturnValue(true); getPlatform.mockReturnValue('ios'); facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); diff --git a/projects/kit/auth-firebase/social/src/kit-social.ts b/projects/kit/auth-firebase/social/src/kit-social.ts index 1ceea7f..8132af1 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.ts @@ -9,7 +9,7 @@ import { reauthenticateWithPopup, signInWithCredential, signInWithPopup, -} from '@angular/fire/auth'; +} from 'firebase/auth'; import { Capacitor } from '@capacitor/core'; import { FacebookLogin } from '@capacitor-community/facebook-login'; import { SignInWithApple } from '@capacitor-community/apple-sign-in'; @@ -24,10 +24,7 @@ export type KitOAuthModeName = 'new' | 'link' | 'credential'; * The mode discriminator. `'credential'` links an email/password to the (re-authenticated) social * account, so it requires the new email/password; `'new'` / `'link'` do not. */ -export type KitOAuthMode = - | { mode: 'new' } - | { mode: 'link' } - | { mode: 'credential'; emailLogin: { email: string; password: string } }; +export type KitOAuthMode = { mode: 'new' } | { mode: 'link' } | { mode: 'credential'; emailLogin: { email: string; password: string } }; /** * The apple identity payload handed to the `success` hook for the backend call. Populated from the @@ -154,10 +151,7 @@ const nextFrame = (): Promise => * cancelled/failed plugin login or a handled Firebase error (the app was already notified via the * hooks). */ -export const kitFacebookLogin = async ( - auth: Auth, - options: KitFacebookLoginOptions, -): Promise<{ status: boolean }> => { +export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOptions): Promise<{ status: boolean }> => { await options.before?.(); try { const nonce = generateNonce(); @@ -248,10 +242,7 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): throw new Error('kit social: no signed-in user to re-authenticate'); } await reauthenticateWithPopup(user, provider); - await linkWithCredential( - user, - EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password), - ); + await linkWithCredential(user, EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password)); } catch (e) { await options.error?.(classifyOAuthError(e), e); return { status: false }; @@ -262,10 +253,7 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): let result; try { - result = - options.mode === 'new' - ? await signInWithPopup(auth, provider) - : await linkWithPopup(requireUser(auth), provider); + result = options.mode === 'new' ? await signInWithPopup(auth, provider) : await linkWithPopup(requireUser(auth), provider); } catch (e) { await options.error?.(classifyOAuthError(e), e); return { status: false }; diff --git a/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts b/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts index 6ee87d1..b051933 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts @@ -34,7 +34,7 @@ const updatePassword = vi.fn(); const signInAnonymously = vi.fn(); const linkWithCredential = vi.fn(); -vi.mock('@angular/fire/auth', () => ({ +vi.mock('firebase/auth', () => ({ reauthenticateWithCredential: (...a: unknown[]) => reauthenticateWithCredential(...a), EmailAuthProvider: { credential: (email: string, password: string) => ({ email, password }) }, onAuthStateChanged: (...a: unknown[]) => onAuthStateChanged(...a), @@ -69,25 +69,19 @@ describe('kitReauthenticateThenMutate', () => { it('throws KitReauthError and skips the mutation when re-auth fails', async () => { reauthenticateWithCredential.mockRejectedValueOnce(fbError('auth/wrong-password')); const mutate = vi.fn(); - await expect(kitReauthenticateThenMutate(authWith({ uid: 'u1' }), 'me@x.com', 'bad', mutate)).rejects.toBeInstanceOf( - KitReauthError, - ); + await expect(kitReauthenticateThenMutate(authWith({ uid: 'u1' }), 'me@x.com', 'bad', mutate)).rejects.toBeInstanceOf(KitReauthError); expect(mutate).not.toHaveBeenCalled(); }); it('throws KitReauthError when there is no signed-in user', async () => { - await expect(kitReauthenticateThenMutate(authWith(null), 'me@x.com', 'pw', vi.fn())).rejects.toBeInstanceOf( - KitReauthError, - ); + await expect(kitReauthenticateThenMutate(authWith(null), 'me@x.com', 'pw', vi.fn())).rejects.toBeInstanceOf(KitReauthError); expect(reauthenticateWithCredential).not.toHaveBeenCalled(); }); it("propagates the mutation's own error unwrapped", async () => { reauthenticateWithCredential.mockResolvedValueOnce({}); const boom = fbError('auth/email-already-in-use'); - await expect( - kitReauthenticateThenMutate(authWith({ uid: 'u1' }), 'me@x.com', 'pw', () => Promise.reject(boom)), - ).rejects.toBe(boom); + await expect(kitReauthenticateThenMutate(authWith({ uid: 'u1' }), 'me@x.com', 'pw', () => Promise.reject(boom))).rejects.toBe(boom); }); }); diff --git a/projects/kit/auth-firebase/src/kit-firebase-auth.ts b/projects/kit/auth-firebase/src/kit-firebase-auth.ts index dcd08f2..94c00c5 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-auth.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-auth.ts @@ -1,8 +1,8 @@ import type { Auth, User, UserCredential } from 'firebase/auth'; export type { User, UserCredential } from 'firebase/auth'; -// Ops must come from @angular/fire/auth — not root `firebase/auth`. @angular/fire 21 rc bundles -// firebase@12 while apps often pin firebase@11; calling the wrong copy's signOut/onAuthStateChanged -// against KIT_FIREBASE_AUTH is a silent no-op (logout appears broken fleet-wide). +// Ops and KIT_FIREBASE_AUTH must resolve to the *same* `firebase/auth` copy. The kit declares +// `firebase` as a peerDependency so the app's single Firebase install is used everywhere; a second +// copy would make signOut/onAuthStateChanged against KIT_FIREBASE_AUTH a silent no-op. import { createUserWithEmailAndPassword, EmailAuthProvider, @@ -17,7 +17,7 @@ import { unlink, updateEmail, updatePassword, -} from '@angular/fire/auth'; +} from 'firebase/auth'; import { Observable } from 'rxjs'; /** @@ -77,12 +77,8 @@ const runAuthFlowVoid = async (op: () => Promise, hooks?: KitAuthHooks): P * stays isolated in the kit). Resolves the credential, or `null` on failure (handed to the `error` * hook). */ -export const kitSignIn = ( - auth: Auth, - email: string, - password: string, - hooks?: KitAuthHooks, -): Promise => runAuthFlow(() => signInWithEmailAndPassword(auth, email, password), hooks); +export const kitSignIn = (auth: Auth, email: string, password: string, hooks?: KitAuthHooks): Promise => + runAuthFlow(() => signInWithEmailAndPassword(auth, email, password), hooks); /** * Create an account and send the verification email. @@ -91,12 +87,7 @@ export const kitSignIn = ( * Bundles the two-step "create → send verification" sequence. Resolves the credential, or `null` on * failure. Any success toast is the caller's, via the `success` hook. */ -export const kitSignUp = ( - auth: Auth, - email: string, - password: string, - hooks?: KitAuthHooks, -): Promise => +export const kitSignUp = (auth: Auth, email: string, password: string, hooks?: KitAuthHooks): Promise => runAuthFlow(async () => { const credential = await createUserWithEmailAndPassword(auth, email, password); await sendEmailVerification(credential.user); @@ -110,8 +101,7 @@ export const kitSignUp = ( * App-specific cleanup (clearing stores, toasts, navigation, third-party logout) is the caller's, * done via the hooks — the kit only owns the Firebase op. `true` on success, `false` on failure. */ -export const kitSignOut = (auth: Auth, hooks?: KitAuthHooks): Promise => - runAuthFlowVoid(() => signOut(auth), hooks); +export const kitSignOut = (auth: Auth, hooks?: KitAuthHooks): Promise => runAuthFlowVoid(() => signOut(auth), hooks); /** Send a password-reset email. `true` on success, `false` on failure. */ export const kitSendPasswordReset = (auth: Auth, email: string, hooks?: KitAuthHooks): Promise => @@ -154,8 +144,8 @@ export const kitSendEmailVerification = (auth: Auth, hooks?: KitAuthHooks): Prom * * @remarks * For use inside {@link kitReauthWithRetry}'s `mutate` callback (after re-authentication). Uses the - * same `@angular/fire/auth` module as {@link KIT_FIREBASE_AUTH} so the dual-firebase-sdk mismatch - * cannot silently no-op. + * same `firebase/auth` copy as {@link KIT_FIREBASE_AUTH} so the dual-firebase-sdk mismatch cannot + * silently no-op. */ export const kitUpdateEmail = async (user: User, newEmail: string): Promise => { await updateEmail(user, newEmail); @@ -192,12 +182,7 @@ export const kitSignInAnonymously = (auth: Auth, hooks?: KitAuthHooks): Promise< * @remarks * Reloads the linked user before resolving. Resolves the updated `User`, or `null` on failure. */ -export const kitLinkEmailPassword = ( - auth: Auth, - email: string, - password: string, - hooks?: KitAuthHooks, -): Promise => +export const kitLinkEmailPassword = (auth: Auth, email: string, password: string, hooks?: KitAuthHooks): Promise => runAuthFlow(async () => { const user = auth.currentUser; if (!user) { @@ -208,16 +193,15 @@ export const kitLinkEmailPassword = ( return linked.user; }, hooks); -// Ops come from `@angular/fire/auth` (same firebase copy as KIT_FIREBASE_AUTH). Types stay on -// `firebase/auth` so apps never import the SDK for covered operations. +// Ops and types come from `firebase/auth` (the single peer-installed copy shared with +// KIT_FIREBASE_AUTH) so apps never import the SDK for covered operations. /** * The current Firebase user as an Observable (emits on every auth-state change; `null` when signed out). * * @remarks - * Wraps `onAuthStateChanged` so consumers get an rxjs stream without pulling in `@angular/fire`'s - * `authState` (or `rxfire`). Emits the current value on subscribe and completes its listener on - * teardown. + * Wraps `firebase/auth`'s `onAuthStateChanged` so consumers get an rxjs stream without pulling in + * `rxfire`. Emits the current value on subscribe and completes its listener on teardown. * * @param auth - the Firebase `Auth` instance (inject `KIT_FIREBASE_AUTH`) */ @@ -379,11 +363,7 @@ export interface KitReauthWithRetryOptions { * @returns `true` if the mutation completed, `false` if the user cancelled * @throws the underlying Firebase error on a non-wrong-password failure, or `mutate`'s own error */ -export const kitReauthWithRetry = async ( - auth: Auth, - currentEmail: string, - options: KitReauthWithRetryOptions, -): Promise => { +export const kitReauthWithRetry = async (auth: Auth, currentEmail: string, options: KitReauthWithRetryOptions): Promise => { const run = options.withLoading ?? ((fn) => fn()); let wrongPasswordRetry = false; for (;;) { @@ -442,8 +422,7 @@ export const kitResolveAuthStatus = (user: User | null, options?: KitResolveAuth if (user === null) { return 'required'; } - const verifiedByProvider = - options?.verifiedProviders?.some((id) => user.providerData.some((p) => p.providerId === id)) ?? false; + const verifiedByProvider = options?.verifiedProviders?.some((id) => user.providerData.some((p) => p.providerId === id)) ?? false; const verified = user.emailVerified || verifiedByProvider || (options?.allowWhen?.(user) ?? false); return verified ? 'user' : 'confirm'; }; diff --git a/projects/kit/auth-firebase/src/kit-firebase-provider.ts b/projects/kit/auth-firebase/src/kit-firebase-provider.ts index c031afa..fd0ed5d 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-provider.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-provider.ts @@ -1,23 +1,21 @@ import type { EnvironmentProviders } from '@angular/core'; import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; import { Capacitor } from '@capacitor/core'; -import type { FirebaseOptions } from '@angular/fire/app'; -import { getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { Auth, getAuth, indexedDBLocalPersistence, initializeAuth, provideAuth } from '@angular/fire/auth'; -import { getAnalytics, provideAnalytics } from '@angular/fire/analytics'; +import type { FirebaseApp, FirebaseOptions } from 'firebase/app'; +import { getApp, getApps, initializeApp } from 'firebase/app'; import type { Auth as FirebaseAuth } from 'firebase/auth'; +import { getAuth, indexedDBLocalPersistence, initializeAuth } from 'firebase/auth'; +import { getAnalytics } from 'firebase/analytics'; /** * DI token for the Firebase `Auth` instance. * * @remarks - * Inject this (`inject(KIT_FIREBASE_AUTH)`) instead of `@angular/fire`'s `Auth`, so the - * `@angular/fire` dependency stays isolated inside the kit. This is the seam that makes the planned - * `@angular/fire` → `firebase/auth` migration a kit-internal change: only {@link provideKitFirebase} - * (which binds this token) has to change; every consumer keeps injecting `KIT_FIREBASE_AUTH`. + * Inject this (`inject(KIT_FIREBASE_AUTH)`) instead of importing `getAuth()` in the app, so the + * Firebase SDK wiring stays isolated inside the kit: only {@link provideKitFirebase} (which binds + * this token) touches initialization; every consumer keeps injecting `KIT_FIREBASE_AUTH`. * - * The value is a `firebase/auth` `Auth` (the SDK type is exposed directly, not re-abstracted — - * Firebase Auth itself is not being dropped, only the `@angular/fire` wrapper). + * The value is a `firebase/auth` `Auth` — the SDK type is exposed directly, not re-abstracted. */ export const KIT_FIREBASE_AUTH = new InjectionToken('@rdlabo/ionic-angular-kit:firebase-auth'); @@ -27,15 +25,19 @@ export interface KitFirebaseConfig { readonly firebaseConfig: FirebaseOptions; } +/** Initialize (or reuse) the Firebase app for the kit's config. */ +const kitFirebaseApp = (config: KitFirebaseConfig): FirebaseApp => (getApps().length ? getApp() : initializeApp(config.firebaseConfig)); + /** * Wire Firebase App + Auth into the application and bind {@link KIT_FIREBASE_AUTH}. * * @remarks - * Replaces each app's hand-rolled `provideFirebaseApp(...)` + `provideAuth(...)` (with its - * native/web persistence branch) with one call, and — crucially — keeps `@angular/fire` out of the - * application: apps inject {@link KIT_FIREBASE_AUTH} and import auth operations/types straight from - * `firebase/auth`. On a native platform the persistence uses `indexedDBLocalPersistence`; on the web - * it uses the default (`getAuth`). + * Replaces each app's hand-rolled `provideFirebaseApp(...)` + `provideAuth(...)` with one call. + * Firebase is initialized eagerly with the vanilla `firebase/app` + `firebase/auth` SDK (no + * `@angular/fire`), and the resulting `Auth` is bound to {@link KIT_FIREBASE_AUTH}. On a native + * platform the persistence uses `indexedDBLocalPersistence`; on the web it uses the default + * (`getAuth`). Apps inject {@link KIT_FIREBASE_AUTH} and call the kit's flow functions, keeping the + * Firebase SDK isolated inside the kit. * * @example * ```ts @@ -44,21 +46,20 @@ export interface KitFirebaseConfig { * }); * ``` */ -export const provideKitFirebase = (config: KitFirebaseConfig): EnvironmentProviders => - makeEnvironmentProviders([ - provideFirebaseApp(() => initializeApp(config.firebaseConfig)), - provideAuth(() => - Capacitor.isNativePlatform() - ? initializeAuth(getApp(), { persistence: indexedDBLocalPersistence }) - : getAuth(), - ), - // Expose @angular/fire's Auth instance under the kit token; phase 3 rebinds this to a - // firebase/auth instance without touching any consumer. - { provide: KIT_FIREBASE_AUTH, useExisting: Auth }, - ]); +export const provideKitFirebase = (config: KitFirebaseConfig): EnvironmentProviders => { + const app = kitFirebaseApp(config); + const auth = Capacitor.isNativePlatform() ? initializeAuth(app, { persistence: indexedDBLocalPersistence }) : getAuth(app); + return makeEnvironmentProviders([{ provide: KIT_FIREBASE_AUTH, useValue: auth }]); +}; /** * Wire Firebase Analytics into the application (optional; only the apps that use it call this). + * + * @remarks + * Analytics is initialized eagerly against the already-initialized Firebase app, so this must be + * called after (or alongside) {@link provideKitFirebase}. */ -export const provideKitFirebaseAnalytics = (): EnvironmentProviders => - makeEnvironmentProviders([provideAnalytics(() => getAnalytics())]); +export const provideKitFirebaseAnalytics = (): EnvironmentProviders => { + getAnalytics(getApp()); + return makeEnvironmentProviders([]); +}; diff --git a/projects/kit/auth-firebase/src/public-api.ts b/projects/kit/auth-firebase/src/public-api.ts index b41aeeb..a079ef7 100644 --- a/projects/kit/auth-firebase/src/public-api.ts +++ b/projects/kit/auth-firebase/src/public-api.ts @@ -1,7 +1,6 @@ -// Firebase auth: the flow library for the fleet. `@angular/fire` is used in *one* place only — the -// DI provider (kit-firebase-provider.ts) — so the planned `@angular/fire` → `firebase/auth` swap is -// provider-local. Apps inject `KIT_FIREBASE_AUTH` and call these pure flow functions, never importing -// the SDK for the covered operations. +// Firebase auth: the flow library for the fleet. The `firebase/auth` SDK is initialized in *one* +// place only — the DI provider (kit-firebase-provider.ts). Apps inject `KIT_FIREBASE_AUTH` and call +// these pure flow functions, never importing the SDK for the covered operations. // // The public surface is intentionally curated: dependency wiring plus pure flow functions (each // pairing a Firebase operation with the uniform `{ before, success, error, finally }` hooks and the diff --git a/projects/kit/live-update/ng-package.json b/projects/kit/live-update/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/kit/live-update/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/kit/live-update/src/live-update-readiness.provider.spec.ts b/projects/kit/live-update/src/live-update-readiness.provider.spec.ts new file mode 100644 index 0000000..497f34c --- /dev/null +++ b/projects/kit/live-update/src/live-update-readiness.provider.spec.ts @@ -0,0 +1,59 @@ +import { ApplicationRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { Capacitor } from '@capacitor/core'; +import { ReplaySubject, Subject } from 'rxjs'; +import { provideLiveUpdateReadiness } from './live-update-readiness.provider'; + +const { ready } = vi.hoisted(() => ({ ready: vi.fn() })); + +vi.mock('@capawesome/capacitor-live-update', () => ({ LiveUpdate: { ready } })); + +describe('provideLiveUpdateReadiness', () => { + const flushFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve))); + + afterEach(() => { + ready.mockReset(); + vi.restoreAllMocks(); + TestBed.resetTestingModule(); + }); + + it('marks a native bundle ready after Angular is stable and navigation completes', async () => { + const stable = new ReplaySubject(1); + const routerEvents = new Subject(); + vi.spyOn(Capacitor, 'isNativePlatform').mockReturnValue(true); + ready.mockResolvedValue({ + previousBundleId: null, + currentBundleId: null, + rollback: false, + }); + TestBed.configureTestingModule({ + providers: [ + provideLiveUpdateReadiness(), + { provide: ApplicationRef, useValue: { isStable: stable } }, + { provide: Router, useValue: { events: routerEvents } }, + ], + }); + TestBed.inject(ApplicationRef); + + stable.next(true); + await Promise.resolve(); + expect(ready).not.toHaveBeenCalled(); + + routerEvents.next(new NavigationEnd(1, '/', '/')); + await flushFrame(); + expect(ready).toHaveBeenCalledOnce(); + }); + + it('does not initialize Live Update on the web', () => { + vi.spyOn(Capacitor, 'isNativePlatform').mockReturnValue(false); + ready.mockResolvedValue({ + previousBundleId: null, + currentBundleId: null, + rollback: false, + }); + TestBed.configureTestingModule({ providers: [provideLiveUpdateReadiness()] }); + TestBed.inject(ApplicationRef); + expect(ready).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/kit/live-update/src/live-update-readiness.provider.ts b/projects/kit/live-update/src/live-update-readiness.provider.ts new file mode 100644 index 0000000..49ff19f --- /dev/null +++ b/projects/kit/live-update/src/live-update-readiness.provider.ts @@ -0,0 +1,30 @@ +import { ApplicationRef, inject, provideEnvironmentInitializer, type EnvironmentProviders } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { LiveUpdate } from '@capawesome/capacitor-live-update'; +import { Capacitor } from '@capacitor/core'; +import { filter, firstValueFrom, take } from 'rxjs'; + +/** + * Marks a native Live Update bundle healthy after Angular is stable and the + * first route has rendered. Web builds are unaffected. + */ +export function provideLiveUpdateReadiness(): EnvironmentProviders { + return provideEnvironmentInitializer(() => { + if (!Capacitor.isNativePlatform()) return; + + const appRef = inject(ApplicationRef); + const router = inject(Router); + void Promise.all([ + firstValueFrom(appRef.isStable.pipe(filter(Boolean), take(1))), + firstValueFrom( + router.events.pipe( + filter((event) => event instanceof NavigationEnd), + take(1), + ), + ), + ]) + .then(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + .then(() => LiveUpdate.ready()) + .catch((error) => console.error('Failed to mark the Live Update bundle as ready.', error)); + }); +} diff --git a/projects/kit/live-update/src/public-api.ts b/projects/kit/live-update/src/public-api.ts new file mode 100644 index 0000000..6f2c7b1 --- /dev/null +++ b/projects/kit/live-update/src/public-api.ts @@ -0,0 +1 @@ +export * from './live-update-readiness.provider'; diff --git a/projects/kit/ng-package.json b/projects/kit/ng-package.json index fe7343e..8fb5c50 100644 --- a/projects/kit/ng-package.json +++ b/projects/kit/ng-package.json @@ -1,7 +1,6 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", "dest": "../../dist/kit", - "allowedNonPeerDependencies": ["@angular/fire"], "lib": { "entryFile": "src/public-api.ts" } diff --git a/projects/kit/package.json b/projects/kit/package.json index 3fb6d7d..8d322ee 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ionic-angular-kit", - "version": "0.0.21", + "version": "0.0.23", "peerDependencies": { "@angular/common": "^21.0.0", "@angular/core": "^21.0.0", @@ -9,6 +9,7 @@ "@ionic/angular": "^8.0.0", "@ionic/storage-angular": "^4.0.0", "@capacitor/core": ">=6.0.0 <9.0.0", + "@capawesome/capacitor-live-update": ">=6.0.0 <9.0.0", "@capacitor/haptics": ">=6.0.0 <9.0.0", "@capacitor/keyboard": ">=6.0.0 <9.0.0", "@capacitor/network": ">=6.0.0 <9.0.0", @@ -19,9 +20,16 @@ "@capacitor-community/apple-sign-in": "*", "@rdlabo/capacitor-brotherprint": ">=6.0.0 <9.0.0", "dom-to-image-more": "^3.0.0", + "firebase": ">=11 <13", "rxjs": "^7.8.0" }, "peerDependenciesMeta": { + "firebase": { + "optional": true + }, + "@capawesome/capacitor-live-update": { + "optional": true + }, "@capacitor/preferences": { "optional": true }, @@ -45,7 +53,6 @@ } }, "dependencies": { - "@angular/fire": "21.0.0-rc.0", "tslib": "^2.3.0" }, "sideEffects": false diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts index deadce4..5b3187e 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts @@ -224,7 +224,8 @@ describe('KitOverlayController', () => { it('auto-anchors a bottom toast above a visible ion-tab-bar', async () => { const tabBar = document.createElement('ion-tab-bar'); - tabBar.getBoundingClientRect = () => ({ height: 50 }) as DOMRect; + tabBar.setAttribute('slot', 'bottom'); + tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; document.body.appendChild(tabBar); try { const { controller, toastCtrl } = setup(); @@ -235,6 +236,69 @@ describe('KitOverlayController', () => { } }); + it('does not anchor when ion-tab-bar has slot=top', async () => { + const tabBar = document.createElement('ion-tab-bar'); + tabBar.setAttribute('slot', 'top'); + tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 50 }) as DOMRect; + document.body.appendChild(tabBar); + try { + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); + } finally { + document.body.removeChild(tabBar); + } + }); + + it('anchors to the bottom tab bar when both top and bottom bars are present', async () => { + const topTabBar = document.createElement('ion-tab-bar'); + topTabBar.setAttribute('slot', 'top'); + topTabBar.getBoundingClientRect = () => ({ height: 50, bottom: 50 }) as DOMRect; + const bottomTabBar = document.createElement('ion-tab-bar'); + bottomTabBar.setAttribute('slot', 'bottom'); + bottomTabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; + document.body.append(topTabBar, bottomTabBar); + try { + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(bottomTabBar); + } finally { + document.body.removeChild(topTabBar); + document.body.removeChild(bottomTabBar); + } + }); + + it('anchors when ion-tab-bar has no slot but sits at the viewport bottom', async () => { + const innerHeight = 800; + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(innerHeight); + const tabBar = document.createElement('ion-tab-bar'); + tabBar.getBoundingClientRect = () => ({ height: 50, bottom: innerHeight }) as DOMRect; + document.body.appendChild(tabBar); + try { + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(tabBar); + } finally { + document.body.removeChild(tabBar); + vi.restoreAllMocks(); + } + }); + + it('does not anchor when ion-tab-bar has no slot and is not at the viewport bottom', async () => { + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + const tabBar = document.createElement('ion-tab-bar'); + tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 200 }) as DOMRect; + document.body.appendChild(tabBar); + try { + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); + } finally { + document.body.removeChild(tabBar); + vi.restoreAllMocks(); + } + }); + it('does not anchor when no tab bar is present', async () => { const { controller, toastCtrl } = setup(); await controller.presentToast({ message: 'Hi' }); @@ -243,7 +307,8 @@ describe('KitOverlayController', () => { it('does not override an explicit positionAnchor', async () => { const tabBar = document.createElement('ion-tab-bar'); - tabBar.getBoundingClientRect = () => ({ height: 50 }) as DOMRect; + tabBar.setAttribute('slot', 'bottom'); + tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; document.body.appendChild(tabBar); const custom = document.createElement('div'); try { diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index 9360e6a..57b26e4 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -111,6 +111,40 @@ type ModalPresentArgs> = [I] extends [never] ? [componentProps?: ModalPropsOf, options?: KitModalPresentOptions] : [componentProps: ModalPropsOf, options?: KitModalPresentOptions]; +const BOTTOM_TAB_BAR_VIEWPORT_MARGIN_PX = 8; + +/** + * Returns a visible `ion-tab-bar` suitable for anchoring a bottom toast above it. + * + * Skips `slot="top"` bars (e.g. airlec desktop layout) and prefers the bar closest to the viewport bottom + * when multiple candidates exist. + */ +function findVisibleBottomTabBar(): HTMLElement | undefined { + let best: HTMLElement | undefined; + let bestBottom = -1; + + for (const tabBar of Array.from(document.querySelectorAll('ion-tab-bar'))) { + if (!(tabBar instanceof HTMLElement)) continue; + if (tabBar.getAttribute('slot') === 'top') continue; + + const rect = tabBar.getBoundingClientRect(); + if (rect.height <= 0) continue; + + const slot = tabBar.getAttribute('slot'); + const isBottom = + slot === 'bottom' || + (slot === null && rect.bottom >= window.innerHeight - BOTTOM_TAB_BAR_VIEWPORT_MARGIN_PX); + if (!isBottom) continue; + + if (rect.bottom > bestBottom) { + bestBottom = rect.bottom; + best = tabBar; + } + } + + return best; +} + /** * Options for {@link KitOverlayController.alertClose}. */ @@ -266,10 +300,11 @@ export class KitOverlayController { * also triggers light native haptic feedback as an intentional kit UX choice. * * Bottom is the fleet-wide default (top left the toast fighting the tab bar and the keyboard). - * For a bottom toast with no explicit `positionAnchor`, if a visible `ion-tab-bar` is present the + * For a bottom toast with no explicit `positionAnchor`, if a visible bottom `ion-tab-bar` is present the * toast is automatically anchored above it (Ionic places a bottom toast above its `positionAnchor`), - * so the toast never sits behind the tabs. Avoiding the on-screen keyboard is handled by the native - * keyboard resize — the anchored/bottom toast rides the shrinking viewport above the keyboard; + * so the toast never sits behind the tabs. Bars with `slot="top"` are ignored (desktop layouts that move + * tabs to the header). Avoiding the on-screen keyboard is handled by the native keyboard resize — the + * anchored/bottom toast rides the shrinking viewport above the keyboard; * Ionic itself has no toast keyboard-avoidance option. An app can override either via `options`. * * @param options - Ionic toast options that override the kit defaults @@ -288,12 +323,12 @@ export class KitOverlayController { swipeGesture: 'vertical', ...options, }; - // Anchor a bottom toast above the tab bar when one is visibly present and the caller did not + // Anchor a bottom toast above the tab bar when a bottom bar is visibly present and the caller did not // set an explicit anchor, so the toast clears the tabs (and rides the keyboard-resized viewport). if (merged.position === 'bottom' && merged.positionAnchor === undefined) { - const tabBar = document.querySelector('ion-tab-bar'); - if (tabBar && tabBar.getBoundingClientRect().height > 0) { - merged.positionAnchor = tabBar as HTMLElement; + const tabBar = findVisibleBottomTabBar(); + if (tabBar) { + merged.positionAnchor = tabBar; } } const toast = await this.#toastCtrl.create(merged); diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index 48d786f..d1a5a76 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -23,7 +23,7 @@ export * from './lib/keyboard/kit-keyboard'; // Theme (`@rdlabo/ionic-angular-kit/theme`), Review (`.../review`), Printer (`.../printer`) and // Firebase auth (`.../auth-firebase`) are separate secondary entry points so their heavy native peers -// (status-bar / in-app-review+preferences / brotherprint+dom-to-image / @angular/fire+firebase) are +// (status-bar / in-app-review+preferences / brotherprint+dom-to-image / firebase) are // only pulled in by apps that import those subpaths. // Auth: functional route guards. diff --git a/projects/kit/tsconfig.spec.json b/projects/kit/tsconfig.spec.json index 344ca94..15b261b 100644 --- a/projects/kit/tsconfig.spec.json +++ b/projects/kit/tsconfig.spec.json @@ -12,6 +12,7 @@ "printer/src/**/*.spec.ts", "theme/src/**/*.spec.ts", "review/src/**/*.spec.ts", + "live-update/src/**/*.spec.ts", "auth-firebase/src/**/*.spec.ts", "auth-firebase/social/src/**/*.spec.ts" ]