Skip to content
Open
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
62 changes: 62 additions & 0 deletions .github/workflows/mobile-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Mobile CI

# Validates the Capacitor config and Fastlane setup on every PR
# that touches the mobile/ folder.

on:
pull_request:
branches: [universe]
paths:
- "mobile/**"
- ".github/workflows/mobile-ci.yml"
push:
branches: [universe]
paths:
- "mobile/**"
- ".github/workflows/mobile-ci.yml"

jobs:
validate:
name: Validate Capacitor config
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
# No npm install needed — config is plain JS with no imports

- name: Verify capacitor config is valid JS
run: node -e "const c = require('./capacitor.config.js'); if (!c.appId || !c.server || !c.server.url) { console.error('Invalid config:', c); process.exit(1); } console.log('Config OK — appId:', c.appId, 'url:', c.server.url);"
# Validates the file parses, exports an object, and has required fields.
# No @capacitor/cli install needed — plain node require() is sufficient.

- name: Run cap doctor (info only)
run: npx --yes @capacitor/cli@8.3.4 doctor 2>&1
continue-on-error: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# cap doctor warns about missing Android/iOS platforms — expected at
# scaffold stage. continue-on-error makes the intent explicit.

fastlane-setup:
name: Validate Gemfile / Fastlane install
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile

steps:
- uses: actions/checkout@v4

- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true
working-directory: mobile

- name: Confirm fastlane installed
run: bundle exec fastlane --version
3 changes: 3 additions & 0 deletions back/src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RoomManagerService, SpaceManagerService } from "@workadventure/messages
import { SharedAdminApi } from "@workadventure/shared-utils/src/SharedAdminApi";
import { DebugController } from "./Controller/DebugController";
import { PrometheusController } from "./Controller/PrometheusController";
import { VersionController } from "./Controller/VersionController";
import { roomManager } from "./RoomManager";
import {
HTTP_PORT,
Expand All @@ -26,6 +27,7 @@ class App {
private prometheusController: PrometheusController;
private debugController: DebugController;
private pingController: PingController;
private versionController: VersionController;

constructor() {
// Création de l'application principale
Expand All @@ -45,6 +47,7 @@ class App {

this.debugController = new DebugController(this.app);
this.pingController = new PingController(this.app);
this.versionController = new VersionController(this.app);
}

public listen(): void {
Expand Down
12 changes: 12 additions & 0 deletions back/src/Controller/VersionController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Express, Request, Response } from "express";
import { getMobileVersionPayload } from "../Services/MobileVersionService";

export class VersionController {
constructor(private app: Express) {
this.app.get("/api/version", this.version.bind(this));
}

private version(req: Request, res: Response): void {
res.status(200).json(getMobileVersionPayload());
}
}
7 changes: 7 additions & 0 deletions back/src/Enum/EnvironmentVariable.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { version } from "../Services/version";
import { EnvironmentVariables } from "./EnvironmentVariableValidator";

const envChecking = EnvironmentVariables.safeParse(process.env);
Expand Down Expand Up @@ -28,6 +29,12 @@ if (!envChecking.success) {
const env: EnvironmentVariables = envChecking.data;

export const PLAY_URL = env.PLAY_URL;
export const MOBILE_WEB_VERSION: string = env.MOBILE_WEB_VERSION ?? version;
export const MOBILE_MIN_NATIVE_VERSION: string = env.MOBILE_MIN_NATIVE_VERSION ?? "1.0.0";
export const MOBILE_LATEST_NATIVE_VERSION: string = env.MOBILE_LATEST_NATIVE_VERSION ?? MOBILE_MIN_NATIVE_VERSION;
export const MOBILE_ANDROID_UPDATE_URL: string =
env.MOBILE_ANDROID_UPDATE_URL ?? "https://play.google.com/store/apps/details?id=net.bawes.universe";
export const MOBILE_IOS_UPDATE_URL: string | undefined = env.MOBILE_IOS_UPDATE_URL;
export const MINIMUM_DISTANCE = env.MINIMUM_DISTANCE;
export const GROUP_RADIUS = env.GROUP_RADIUS;
export const ADMIN_API_URL = env.ADMIN_API_URL;
Expand Down
37 changes: 37 additions & 0 deletions back/src/Enum/EnvironmentVariableValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,45 @@ import {
toNumber,
} from "@workadventure/shared-utils/src/EnvironmentVariables/EnvironmentVariableUtils";

const semanticVersionPattern =
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const SemanticVersionAsString = z
.string()
.refine((value) => semanticVersionPattern.test(value), "must be a semantic version like X.Y.Z");

export const EnvironmentVariables = z.object({
PLAY_URL: z.string().url().describe("Public URL of the play/frontend service"),
MOBILE_WEB_VERSION: z
.string()
.optional()
.transform(emptyStringToUndefined)
.describe("Version string returned by /api/version for the live web application."),
MOBILE_MIN_NATIVE_VERSION: z
.string()
.optional()
.transform(emptyStringToUndefined)
.pipe(SemanticVersionAsString.optional())
.describe("Minimum native mobile shell version allowed to open the live web app."),
MOBILE_LATEST_NATIVE_VERSION: z
.string()
.optional()
.transform(emptyStringToUndefined)
.pipe(SemanticVersionAsString.optional())
.describe("Latest recommended native mobile shell version."),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
MOBILE_ANDROID_UPDATE_URL: z
.string()
.url()
.or(z.literal(""))
.optional()
.transform(emptyStringToUndefined)
.describe("Android store URL used by the mobile update prompt."),
MOBILE_IOS_UPDATE_URL: z
.string()
.url()
.or(z.literal(""))
.optional()
.transform(emptyStringToUndefined)
.describe("iOS App Store URL used by the mobile update prompt."),
MINIMUM_DISTANCE: PositiveIntAsString.optional()
.transform((val) => toNumber(val, 64))
.describe("Minimum distance (in pixels) before users are considered to be in proximity. Defaults to 64"),
Expand Down
34 changes: 34 additions & 0 deletions back/src/Services/MobileVersionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
MOBILE_ANDROID_UPDATE_URL,
MOBILE_IOS_UPDATE_URL,
MOBILE_LATEST_NATIVE_VERSION,
MOBILE_MIN_NATIVE_VERSION,
MOBILE_WEB_VERSION,
} from "../Enum/EnvironmentVariable";

export interface MobileVersionPayload {
webVersion: string;
minNativeVersion: string;
latestNativeVersion: string;
updateUrl: {
android: string;
ios?: string;
};
}

export function getMobileVersionPayload(): MobileVersionPayload {
const updateUrl: MobileVersionPayload["updateUrl"] = {
android: MOBILE_ANDROID_UPDATE_URL,
};

if (MOBILE_IOS_UPDATE_URL) {
updateUrl.ios = MOBILE_IOS_UPDATE_URL;
}

return {
webVersion: MOBILE_WEB_VERSION,
minNativeVersion: MOBILE_MIN_NATIVE_VERSION,
latestNativeVersion: MOBILE_LATEST_NATIVE_VERSION,
updateUrl,
};
}
116 changes: 116 additions & 0 deletions back/tests/VersionController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Express, Request, Response } from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { VersionController as VersionControllerType } from "../src/Controller/VersionController";

const mobileEnvKeys = [
"PLAY_URL",
"MOBILE_WEB_VERSION",
"MOBILE_MIN_NATIVE_VERSION",
"MOBILE_LATEST_NATIVE_VERSION",
"MOBILE_ANDROID_UPDATE_URL",
"MOBILE_IOS_UPDATE_URL",
] as const;

interface VersionResponse {
webVersion: string;
minNativeVersion: string;
latestNativeVersion: string;
updateUrl: {
android: string;
ios?: string;
};
}

async function loadVersionController(): Promise<typeof VersionControllerType> {
vi.resetModules();
process.env.PLAY_URL = "http://play.workadventure.localhost";
const { VersionController } = await import("../src/Controller/VersionController");
return VersionController;
}

function createRouteHarness(): {
routes: Map<string, (req: Request, res: Response) => void>;
app: Express;
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn<(body: VersionResponse) => void>>;
res: Response;
} {
const routes = new Map<string, (req: Request, res: Response) => void>();
const app = {
get: (path: string, handler: (req: Request, res: Response) => void) => {
routes.set(path, handler);
},
} as unknown as Express;
const status = vi.fn().mockReturnThis();
const json = vi.fn<(body: VersionResponse) => void>();
const res = { status, json } as unknown as Response;

return { routes, app, status, json, res };
}

afterEach(() => {
for (const key of mobileEnvKeys) {
delete process.env[key];
}
});

describe("VersionController", () => {
it("exposes mobile update metadata", async () => {
const VersionController = await loadVersionController();
const { routes, app, status, json, res } = createRouteHarness();

new VersionController(app);
routes.get("/api/version")?.({} as Request, res);

expect(status).toHaveBeenCalledWith(200);
const payload = json.mock.calls[0]?.[0];
expect(payload).toMatchObject({
webVersion: "dev",
minNativeVersion: "1.0.0",
latestNativeVersion: "1.0.0",
updateUrl: {
android: "https://play.google.com/store/apps/details?id=net.bawes.universe",
},
});
expect(payload?.updateUrl).not.toHaveProperty("ios");
});

it("exposes configured mobile update metadata", async () => {
process.env.MOBILE_WEB_VERSION = "2026.05.15";
process.env.MOBILE_MIN_NATIVE_VERSION = "1.2.3";
process.env.MOBILE_LATEST_NATIVE_VERSION = "1.4.0";
process.env.MOBILE_ANDROID_UPDATE_URL = "https://play.google.com/store/apps/details?id=net.bawes.custom";
process.env.MOBILE_IOS_UPDATE_URL = "https://apps.apple.com/app/id1234567890";
const VersionController = await loadVersionController();
const { routes, app, status, json, res } = createRouteHarness();

new VersionController(app);
routes.get("/api/version")?.({} as Request, res);

expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({
webVersion: "2026.05.15",
minNativeVersion: "1.2.3",
latestNativeVersion: "1.4.0",
updateUrl: {
android: "https://play.google.com/store/apps/details?id=net.bawes.custom",
ios: "https://apps.apple.com/app/id1234567890",
},
});
});

it("keeps minimum and latest native versions distinct", async () => {
process.env.MOBILE_MIN_NATIVE_VERSION = "1.0.0";
process.env.MOBILE_LATEST_NATIVE_VERSION = "1.2.0";
const VersionController = await loadVersionController();
const { routes, app, json, res } = createRouteHarness();

new VersionController(app);
routes.get("/api/version")?.({} as Request, res);

expect(json.mock.calls[0]?.[0]).toMatchObject({
minNativeVersion: "1.0.0",
latestNativeVersion: "1.2.0",
});
});
});
5 changes: 5 additions & 0 deletions docs/others/self-hosting/env-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ Environment variables for the Back service (backend API).
| Variable | Required | Description |
|----------|----------|-------------|
| `PLAY_URL` | Yes | Public URL of the play/frontend service |
| `MOBILE_WEB_VERSION` | No | Version string returned by /api/version for the live web application. |
| `MOBILE_MIN_NATIVE_VERSION` | Yes | Minimum native mobile shell version allowed to open the live web app. |
| `MOBILE_LATEST_NATIVE_VERSION` | Yes | Latest recommended native mobile shell version. |
| `MOBILE_ANDROID_UPDATE_URL` | No | Android store URL used by the mobile update prompt. |
| `MOBILE_IOS_UPDATE_URL` | No | iOS App Store URL used by the mobile update prompt. |
| `MINIMUM_DISTANCE` | No | Minimum distance (in pixels) before users are considered to be in proximity. Defaults to 64 |
| `GROUP_RADIUS` | No | Radius (in pixels) of a group/bubble. Defaults to 48 |
| `ADMIN_API_URL` | No | URL of the admin API for centralized configuration |
Expand Down
31 changes: 31 additions & 0 deletions mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Dependencies
node_modules/

# Capacitor platform folders — added by separate PRs
# android/ is added by issue #4
# ios/ is added by issue #5
android/
ios/

# Fastlane build outputs
fastlane/report.xml
fastlane/screenshots
fastlane/test_output

# Signing — NEVER commit real keystores or certificates
*.keystore
*.jks
google-play-key.json
certs/
*.p12
*.mobileprovision

# Bundler
.bundle/
vendor/

# Build artifacts
dist/
*.apk
*.aab
*.ipa
9 changes: 9 additions & 0 deletions mobile/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

source 'https://rubygems.org'

# Fastlane — used by both Android (issue #4) and iOS (issue #5) lanes
gem 'fastlane'

# For automated version and build number bumping
gem 'fastlane-plugin-versioning'
Loading