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
114 changes: 102 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@
# - @tm/server changed -> build the image, push to GHCR, run migrations, then point
# the Azure Container App at the new image.
# - @tm/web changed -> build the Vite bundle and upload it to the Static Web App.
# A change to a shared package (@tm/shared, @tm/protocol, @tm/ui) or the lockfile triggers
# both, since either app could be affected.
# - @tm/mobile changed -> same, to ITS OWN Static Web App on its own subdomain
# (docs/plan/README.md Phase 27, Decision 3).
# A change to @tm/shared or @tm/protocol (used by all three) or the lockfile triggers every
# job it applies to. @tm/ui and @tm/cloud are browser-only and trigger both `web` and `mobile`.
#
# It does NOT touch the desktop app. apps/client is released by release.yml, which runs off
# the same push and follows RELEASE.md's procedure — and nothing here should ever try to do
# that. A push containing only apps/client changes deploys nothing.
#
# The `mobile` job is written and merged independently of the one-time Azure setup it needs
# (docs/09, "Adding apps/mobile's Static Web App") — its deploy step checks
# AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE itself and stays inert, rather than the job failing
# loudly on every push, until a human has done that setup. `job.if` cannot do this check: the
# `secrets` context is not available there, only in a step's own `if`.
#
# ── One-time setup (repo Settings → Secrets and variables → Actions) ──
# AZURE_CLIENT_ID app registration (or user-assigned MI) client id
# AZURE_TENANT_ID Entra tenant id
# AZURE_SUBSCRIPTION_ID the subscription holding the `taskmanager` RG
# AZURE_STATIC_WEB_APPS_API_TOKEN az staticwebapp secrets list -n taskmanager-web \
# -g taskmanager --query properties.apiKey -o tsv
# VITE_CLOUD_IAM_CLIENT_ID this web build's public vipper.iam client id
# AZURE_CLIENT_ID app registration (or user-assigned MI) client id
# AZURE_TENANT_ID Entra tenant id
# AZURE_SUBSCRIPTION_ID the subscription holding the `taskmanager` RG
# AZURE_STATIC_WEB_APPS_API_TOKEN az staticwebapp secrets list -n taskmanager-web \
# -g taskmanager --query properties.apiKey -o tsv
# AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE same, for -n taskmanager-mobile (docs/09)
# VITE_CLOUD_IAM_CLIENT_ID this web build's public vipper.iam client id
#
# Azure login uses OIDC — no stored credential. Create a federated credential on the app
# registration and give its service principal a role on the `taskmanager` resource group:
Expand Down Expand Up @@ -73,6 +82,7 @@ jobs:
outputs:
server: ${{ steps.filter.outputs.server }}
web: ${{ steps.filter.outputs.web }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
Expand All @@ -91,15 +101,26 @@ jobs:
- 'packages/shared/**'
- 'packages/protocol/**'
- 'packages/ui/**'
- 'packages/cloud/**'
- 'pnpm-lock.yaml'
- '.github/workflows/deploy.yml'
# The one apps/client path that is a WEB input. vite.config.ts bakes the
# version of record into the bundle for the status bar, so a bump changes
# what the web says about itself and the bundle has to be rebuilt to say it.
# Without this, the deployed web kept whatever number it was built with —
# it sat on v0.78.2 while the desktop was released at v0.86.0. Nothing else
# under apps/client belongs here: that app is release.yml's business.
# under apps/client belongs here: that app is release.yml's business. (Not
# mobile's filter below — apps/mobile/vite.config.ts bakes ITS OWN
# package.json's version, already covered by apps/mobile/** there.)
- 'apps/client/package.json'
mobile:
- 'apps/mobile/**'
- 'packages/shared/**'
- 'packages/protocol/**'
- 'packages/ui/**'
- 'packages/cloud/**'
- 'pnpm-lock.yaml'
- '.github/workflows/deploy.yml'

# ── @tm/server → GHCR → migrate job → Container App ────────────────────────
server:
Expand Down Expand Up @@ -265,9 +286,9 @@ jobs:
VITE_CLOUD_API_BASE: https://tasks-api.vipper.network
VITE_CLOUD_IAM_ISSUER: https://auth.vipper.network/oidc
VITE_CLOUD_IAM_CLIENT_ID: ${{ secrets.VITE_CLOUD_IAM_CLIENT_ID }}
# turbo builds @tm/shared, @tm/protocol and @tm/ui first (build.dependsOn ^build);
# @tm/web imports them through their `exports`, i.e. their dist/, which does not
# exist after a clean install.
# turbo builds @tm/shared, @tm/protocol, @tm/ui and @tm/cloud first (build.dependsOn
# ^build); @tm/web imports them through their `exports`, i.e. their dist/, which does
# not exist after a clean install.
run: pnpm exec turbo run build --filter=@tm/web

# Vite does not copy this (there is no public/ dir) and Static Web Apps only reads
Expand All @@ -284,3 +305,72 @@ jobs:
skip_app_build: true
skip_api_build: true
output_location: ''

# ── @tm/mobile → its own Static Web App ─────────────────────────────────────
# Mirrors `web` above, deployed to a separate SWA on its own subdomain (docs/plan/README.md
# Phase 27, Decision 3) so a phone client never shares a route table or a deploy with the
# browser one.
mobile:
needs: changes
if: needs.changes.outputs.mobile == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Written and merged before the one-time Azure setup this job needs necessarily exists
# (docs/plan/README.md Phase 27, Decision 4) — checked in a STEP, not a job-level `if`,
# because the `secrets` context is unavailable there. Without this, a job whose token
# secret is still unset would fail the whole workflow on every push to `development`
# that touches apps/mobile, for everyone, until a human does the one-time setup in
# docs/09 ("Adding apps/mobile's Static Web App").
- name: Check the mobile Static Web Apps token is configured
id: token
run: |
if [ -n "${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE }}" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::warning::AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE is not set — skipping the mobile deploy. See docs/09-deploying-the-cloud-service.md, 'Adding apps/mobile's Static Web App'."
fi

- uses: pnpm/action-setup@v4
if: steps.token.outputs.configured == 'true'
- uses: actions/setup-node@v4
if: steps.token.outputs.configured == 'true'
with:
node-version: 22
cache: pnpm

- name: Install dependencies
if: steps.token.outputs.configured == 'true'
run: pnpm install --frozen-lockfile

# Same reasoning as the web build's env block above — Vite compiles these into the
# bundle at build time. VITE_CLOUD_IAM_CLIENT_ID is `taskmanager-mobile` unconditionally
# (apps/mobile/.env.example): its own registered public vipper.iam client id, a separate
# registration from apps/web's `taskmanager-web`, so a redirect-URI allowlist entry for
# one build can never be replayed against the other. Not a secret, unlike the web job's
# (docs/11 explains that one is a secret only by storage, not by nature) — this one is
# never stored as one at all.
- name: Build the mobile client
if: steps.token.outputs.configured == 'true'
env:
VITE_CLOUD_API_BASE: https://tasks-api.vipper.network
VITE_CLOUD_IAM_ISSUER: https://auth.vipper.network/oidc
VITE_CLOUD_IAM_CLIENT_ID: taskmanager-mobile
run: pnpm exec turbo run build --filter=@tm/mobile

- name: Include the Static Web Apps config
if: steps.token.outputs.configured == 'true'
run: cp apps/mobile/staticwebapp.config.json apps/mobile/dist/

- name: Deploy to Static Web Apps
if: steps.token.outputs.configured == 'true'
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE }}
action: upload
app_location: apps/mobile/dist
skip_app_build: true
skip_api_build: true
output_location: ''
10 changes: 6 additions & 4 deletions apps/client/scripts/verify-remote-ipc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ const app = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(app, '..', '..');
const sharedSrc = join(repo, 'packages', 'shared', 'src');
const protocolSrc = join(repo, 'packages', 'protocol', 'src');
const webSrc = join(repo, 'apps', 'web', 'src');
// `HttpTransport` and `PolledEventBus` moved out of apps/web/src and into `@tm/cloud`
// (Phase 27 step 3, so both apps/web and apps/mobile can share one sync layer) — this
// reads their sources directly, same as it always has, just from their new home.
const cloudSrc = join(repo, 'packages', 'cloud', 'src');

/**
* Everything this script writes lives here, INSIDE the app rather than in the temp dir, for
Expand Down Expand Up @@ -99,7 +102,6 @@ export default { app, ipcMain, safeStorage };
'@tm/shared': sharedSrc,
'@tm/protocol': protocolSrc,
'@tm/ui/transport': empty,
'@web': webSrc,
electron: electronStub,
},
},
Expand Down Expand Up @@ -138,8 +140,8 @@ import { createStore } from '${join(app, 'src/main/store').replace(/\\/g, '/')}'
import { RelayRegistry } from '${join(app, 'src/main/ipcRegistry').replace(/\\/g, '/')}';
import { CommandQueue } from '${join(app, 'src/main/commandQueue').replace(/\\/g, '/')}';
import { applyCloudCommand } from '${join(app, 'src/main/cloudCommands').replace(/\\/g, '/')}';
import { HttpTransport } from '${join(webSrc, 'board/httpTransport').replace(/\\/g, '/')}';
import { PolledEventBus } from '${join(webSrc, 'board/polledEvents').replace(/\\/g, '/')}';
import { HttpTransport } from '${join(cloudSrc, 'board/httpTransport').replace(/\\/g, '/')}';
import { PolledEventBus } from '${join(cloudSrc, 'board/polledEvents').replace(/\\/g, '/')}';
import {
acknowledgeable,
isDeliverable,
Expand Down
12 changes: 7 additions & 5 deletions apps/client/scripts/verify-remote-sse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* (`ipcEventFanout.test.ts`), the desktop's forwarder (`cloudEventForwarder.test.ts`), the
* server's ring and its subscriptions (`eventBus.test.ts`), the SSE framing
* (`sseStream.test.ts`), the browser's reader (`sseEvents.test.ts`) and the composite that
* chooses between push and poll (`apps/web/src/board/eventBus.test.ts`). What none of them
* chooses between push and poll (`packages/cloud/src/board/eventBus.test.ts`). What none of them
* covers is the thing that actually has to work: an agent's line, emitted on the desktop,
* arriving in a browser — through the forwarder's queue, a real `POST /v1/events`, the
* server's replay ring, real `text/event-stream` bytes, the browser's `ReadableStream`
Expand Down Expand Up @@ -48,7 +48,10 @@ const app = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(app, '..', '..');
const sharedSrc = join(repo, 'packages', 'shared', 'src');
const protocolSrc = join(repo, 'packages', 'protocol', 'src');
const webSrc = join(repo, 'apps', 'web', 'src');
// `SseEventStream` and `CloudEventBus` moved out of apps/web/src and into `@tm/cloud`
// (Phase 27 step 3, so both apps/web and apps/mobile can share one sync layer) — this
// reads their sources directly, same as it always has, just from their new home.
const cloudSrc = join(repo, 'packages', 'cloud', 'src');
const serverSrc = join(repo, 'apps', 'server', 'src');

/**
Expand Down Expand Up @@ -113,7 +116,6 @@ async function bundle(entry, outDir) {
'@tm/shared': sharedSrc,
'@tm/protocol': protocolSrc,
'@tm/ui/transport': empty,
'@web': webSrc,
'@nestjs/common': nestStub,
electron: electronStub,
},
Expand Down Expand Up @@ -158,8 +160,8 @@ const SCENARIO = `
import { CloudEventForwarder } from '${posix(join(app, 'src/main/cloudEventForwarder'))}';
import { EventBus } from '${posix(join(serverSrc, 'events/eventBus'))}';
import { openEventStream } from '${posix(join(serverSrc, 'events/sseStream'))}';
import { SseEventStream } from '${posix(join(webSrc, 'board/sseEvents'))}';
import { CloudEventBus } from '${posix(join(webSrc, 'board/eventBus'))}';
import { SseEventStream } from '${posix(join(cloudSrc, 'board/sseEvents'))}';
import { CloudEventBus } from '${posix(join(cloudSrc, 'board/eventBus'))}';
import { MAX_EVENT_BYTES } from '${posix(join(sharedSrc, 'ipcEventFanout'))}';

let failures = 0;
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Local development configuration for @tm/mobile. Copy to .env.local and edit if you need
# to. Vite only exposes vars prefixed VITE_ to client code — see apps/web/.env.example,
# whose own header explains why nothing here is secret.

# The @tm/server root this client polls (GET /v1/board) and posts commands to
# (POST /v1/commands). No trailing slash. Matches apps/server's PORT (.env.example).
VITE_CLOUD_API_BASE=http://localhost:3100

# vipper.iam — the OIDC issuer this client sends the user's browser to, and this build's
# own registered PUBLIC client id (grants: authorization_code + refresh_token,
# token_endpoint_auth_method: none), a separate registration from apps/web's
# `taskmanager-web` (docs/plan/README.md, Phase 27, Decision 4).
VITE_CLOUD_IAM_ISSUER=https://auth.vipper.network/oidc
VITE_CLOUD_IAM_CLIENT_ID=taskmanager-mobile
28 changes: 28 additions & 0 deletions apps/mobile/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover is what lets env(safe-area-inset-*) resolve to the notch/gesture-bar
insets instead of 0 — without it the tab bar in MobileShell would sit under Android's
gesture bar rather than padding above it. interactive-widget=resizes-content (Chrome
108+, which is what an installed WebAPK runs on — Decision 2) makes the on-screen
keyboard shrink the layout viewport the same way the browser's own address bar
already does, so 100dvh — already relied on everywhere for that (MobileShell.tsx) —
also keeps TaskScreen's fixed composer band above the keyboard rather than under it. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<!-- Matches useGlobalStyles' page background (packages/ui/src/theme.ts) and the
manifest's own theme_color/background_color, so the browser chrome and the
install splash screen never flash a colour the app itself doesn't paint. -->
<meta name="theme-color" content="#1f1f1f" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon-192.png" />
<title>VIPPER Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@tm/mobile",
"version": "0.1.0",
"description": "VIPPER Task Manager Cloud — the Android client, an installable PWA. Vite + React + Fluent UI, deployed to its own Azure Static Web App (docs/plan/README.md, Phase 27). Same cloud sync as apps/web, through @tm/cloud; its own shell, because a phone has no rail and no mouse.",
"license": "UNLICENSED",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build && vite build -c vite.sw.config.ts",
"preview": "vite preview",
"typecheck:app": "tsc --noEmit -p tsconfig.app.json",
"typecheck:sw": "tsc --noEmit -p tsconfig.sw.json",
"typecheck": "npm run typecheck:app && npm run typecheck:sw",
"icons": "node ../../scripts/make-mobile-icons.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.270",
"@tm/cloud": "workspace:*",
"@tm/protocol": "workspace:*",
"@tm/shared": "workspace:*",
"@tm/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.5"
}
}
Binary file added apps/mobile/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions apps/mobile/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "/",
"name": "VIPPER Task Manager",
"short_name": "Task Manager",
"description": "VIPPER Task Manager Cloud — the Android client.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1f1f1f",
"background_color": "#1f1f1f",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Loading
Loading