Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/actions/validate-live-update/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
107 changes: 107 additions & 0 deletions .github/actions/validate-live-update/validate-live-update.mjs
Original file line number Diff line number Diff line change
@@ -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}).`);
1 change: 1 addition & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ presentPopover<O>(

presentToast(options: ToastOptions): Promise<HTMLIonToastElement>
// kit defaults: position='bottom', duration=2000, swipeGesture='vertical'
// A bottom toast with no explicit positionAnchor auto-anchors above a visible <ion-tab-bar>
// (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 <ion-tab-bar>
// (`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<void>
Expand Down Expand Up @@ -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(),
```
Expand Down
4 changes: 2 additions & 2 deletions projects/kit/auth-firebase/social/src/kit-social.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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' } });
Expand Down
22 changes: 5 additions & 17 deletions projects/kit/auth-firebase/social/src/kit-social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -154,10 +151,7 @@ const nextFrame = (): Promise<void> =>
* 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();
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand Down
14 changes: 4 additions & 10 deletions projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
});
});

Expand Down
Loading