capgo and adk autoupdates. card tags

This commit is contained in:
azykov@mail.ru
2026-09-29 19:36:08 +03:00
parent 1981d976eb
commit b6fb2fe40f
43 changed files with 1329 additions and 54 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
dist-capacitor/ dist-capacitor/
mobile-client/updates/
mobile-client/android/keystore.properties
data/ data/
.env .env
*.log *.log
+38
View File
@@ -45,6 +45,44 @@ pnpm --filter @cardy/mobile-client build:android # web build for Capacitor + c
Then open `mobile-client/android` in Android Studio (or `gradlew assembleDebug` there) to build the APK. Then open `mobile-client/android` in Android Studio (or `gradlew assembleDebug` there) to build the APK.
The app registers `cardy://login?...` links (key QR codes) and asks for camera access when scanning. The app registers `cardy://login?...` links (key QR codes) and asks for camera access when scanning.
### Live updates (no APK reinstall)
The APK contains the web build and works fully offline. Newer web builds are delivered over the air
(`@capgo/capacitor-updater` in manual mode, no Capgo cloud, telemetry disabled):
```bash
pnpm --filter @cardy/mobile-client publish:android # build + zip into mobile-client/updates/android
```
The mobile-client server on :10003 serves it at `https://dev.aggtaa.com/m/android/version.json`.
On launch and on every return to the foreground (when online) the app checks it, downloads a newer
bundle in the background and reloads into it on the card list; on a card or in the editor it waits
(`next()`: applied on backgrounding). A bundle that fails to start is rolled back automatically.
Versions come from the build time (`2026.929.151230` = 2026-09-29 15:12:30 UTC).
### APK self-update (native part changed)
Changes to the native part (plugins, `AndroidManifest.xml`, Java code) need a new APK:
```bash
pnpm --filter @cardy/mobile-client publish:android-apk # signed release APK into mobile-client/updates/android
```
It needs `mobile-client/android/keystore.properties` (copy `keystore.properties.example`, not committed)
with the release key. `versionCode` is the build time (`yyDDDHHmm`, UTC), `versionName` the web version.
Installed apps show "A new version of the app is available" on the card list; Install downloads the
APK and hands it to Android's installer (first time: allow "Install unknown apps" for Cardy, then
confirm the system dialog). Web updates published after an APK release require that APK or newer.
All builds (also Android Studio debug builds) are signed with the release key, so they can replace
each other. An app signed with another key (e.g. the old debug key) must be uninstalled once.
### Live reload during development
`pnpm --filter @cardy/mobile-client android:dev` (with `pnpm --filter @cardy/mobile-client dev` running
and a device connected via adb) installs an app that loads the dev server through `adb reverse`.
Run `build:android` again before building an APK for real use.
`server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`. `server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`.
If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add
`X-Cardy-Proxy-Secret` on the `/api/admin` route. `X-Cardy-Proxy-Secret` on the `/api/admin` route.
+11 -6
View File
@@ -1,6 +1,6 @@
# Cardy — техническое задание, версия 2 # Cardy — техническое задание, версия 2
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональное лого + опциональные картинки (фото/скриншоты). Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональные теги + опциональное лого + опциональные картинки (фото/скриншоты).
Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались). Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались).
Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят. Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят.
@@ -102,6 +102,7 @@ cards:
code: "4600000000000" code: "4600000000000"
codeType: EAN13 codeType: EAN13
comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто
tags: [продукты, семья] # [] если нет
logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null
images: # порядок = порядок добавления images: # порядок = порядок добавления
- { id: 5f6a…, file: 5f6a….webp, mime: image/webp } - { id: 5f6a…, file: 5f6a….webp, mime: image/webp }
@@ -181,8 +182,8 @@ cards:
|---|---| |---|---|
| `GET /api/auth` | проверка ключа; возвращает email и группу | | `GET /api/auth` | проверка ключа; возвращает email и группу |
| `GET /api/cards` | все неудалённые карты группы, без пагинации | | `GET /api/cards` | все неудалённые карты группы, без пагинации |
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment` и файл `logo`); возвращает карту с ID | | `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID |
| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий; опционально файл `logo`, опционально `removeLogo=true`) | | `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `logo`, опционально `removeLogo=true`) |
| `DELETE /api/cards/:id` | пометить карту удалённой | | `DELETE /api/cards/:id` | пометить карту удалённой |
| `GET /api/cards/:id/logo` | лого | | `GET /api/cards/:id/logo` | лого |
| `GET /api/cards/:id/images/:imageId` | картинка | | `GET /api/cards/:id/images/:imageId` | картинка |
@@ -191,6 +192,8 @@ cards:
Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись. Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись.
**Теги карты.** Вводятся строкой через запятую; у каждого тега обрезаются ведущие и концевые пробелы, пустые теги отбрасываются, точные повторы тоже (`один,,, два` → `[один, два]`). Не больше 30 тегов, каждый до 50 символов. API принимает строку или массив строк (к каждому элементу применяется то же правило).
## 4. Клиентское мобильное приложение ## 4. Клиентское мобильное приложение
React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (`vite preview` собранной версии; `vite` — для разработки) с базовым путём `/m` (`https://dev.aggtaa.com/m`); прокси срезает `/m`, сервер восстанавливает его сам. Сборка для Capacitor (`pnpm build:android`) — с относительным базовым путём, в `dist-capacitor`. Service worker кэширует приложение целиком (включая `bwip-js` и wasm-модуль сканера) для работы офлайн. React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (`vite preview` собранной версии; `vite` — для разработки) с базовым путём `/m` (`https://dev.aggtaa.com/m`); прокси срезает `/m`, сервер восстанавливает его сам. Сборка для Capacitor (`pnpm build:android`) — с относительным базовым путём, в `dist-capacitor`. Service worker кэширует приложение целиком (включая `bwip-js` и wasm-модуль сканера) для работы офлайн.
@@ -200,11 +203,13 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически. - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
- **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает. - **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне. - **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить». - **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы. - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
- **Экран карты**: название, код (отрисовка `bwip-js`), комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). - **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
- **Экран добавления и редактирования**: название, комментарий (многострочное поле), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
- **Обновления Android-приложения без переустановки APK** (`@capgo/capacitor-updater` в ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн. `pnpm publish:android` собирает новую версию (номер — время сборки в UTC, `ГГГГ.МДД.Ччммсс`), кладёт zip и `version.json` (версия, SHA-256, минимальный `versionCode` APK) в `mobile-client/updates/android`; сервер `mobile-client` раздаёт их по `/m/android/`. При запуске и каждом возврате в приложение (не чаще раза в минуту, только при наличии связи) приложение проверяет `version.json` и скачивает более новую совместимую версию в фоне. На экране списка карт (разблокировано, без идущей загрузки картинок) она применяется сразу — перезагрузкой без повторного PIN; на экране карты, в редакторе, на экране входа или PIN — откладывается (`next()`: применяется при уходе в фон или при возврате к списку). Версия, не подтвердившая запуск за 10 секунд, откатывается к предыдущей. Установка нового APK сбрасывает скачанные версии.
- **Обновление самого APK** (при изменениях нативной части: плагины, манифест, Java). `pnpm publish:android-apk` собирает релизный APK, подписанный ключом из `android/keystore.properties` (не в git; тем же ключом подписываются и отладочные сборки), с `versionCode` из времени сборки (`ггДДДЧЧмм`, UTC) и `versionName` = версия веб-сборки, и кладёт его вместе с `apk.json` (versionCode, versionName, SHA-256, размер) в `mobile-client/updates/android`. При запуске и возврате в приложение (не чаще раза в минуту) приложение сверяет `apk.json` со своим `versionCode` и при наличии более нового APK показывает на экране списка карт баннер «A new version of the app is available» с кнопкой Install: APK скачивается с прогрессом, проверяется SHA-256 и передаётся системному установщику Android (`PackageInstaller`). В первый раз нужно разрешить Cardy «Установку неизвестных приложений» (приложение открывает этот экран настроек), затем подтвердить системный диалог; на Android 12+ последующие обновления могут устанавливаться без подтверждения, если это разрешит система. Веб-обновления, опубликованные после выпуска APK, требуют этот APK или новее (`minNativeBuild`), старые установки получают предложение обновить APK.
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет). - Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
## 5. Тесты ## 5. Тесты
+35 -2
View File
@@ -1,5 +1,20 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
// ---- Cardy: versioning and signing ----
// versionCode from the build time (UTC): yyDDDHHmm (year, day of year, time), e.g. 262721430.
// Grows with every build and stays below Android's limit (2100000000) until 2099.
// Override with -PcardyVersionCode=... (the APK publishing script passes the value it records).
def cardyVersionCode = project.findProperty('cardyVersionCode') ?: new Date().format('yyDDDHHmm', TimeZone.getTimeZone('UTC'))
def cardyVersionName = project.findProperty('cardyVersionName') ?: '1.0'
// Release key: android/keystore.properties (not in git) with storeFile, storePassword, keyAlias, keyPassword.
// Debug builds use the same key so that Android Studio builds and published APKs can replace each other.
def keystoreProps = new Properties()
def keystoreFile = rootProject.file('keystore.properties')
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
def hasReleaseKey = ['storeFile', 'storePassword', 'keyAlias', 'keyPassword'].every { keystoreProps.getProperty(it)?.trim() }
android { android {
namespace = "com.aggtaa.cardy" namespace = "com.aggtaa.cardy"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
@@ -7,8 +22,8 @@ android {
applicationId "com.aggtaa.cardy" applicationId "com.aggtaa.cardy"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode cardyVersionCode as Integer
versionName "1.0" versionName cardyVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,14 +31,32 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
signingConfigs {
if (hasReleaseKey) {
cardy {
storeFile file(keystoreProps.getProperty('storeFile').trim())
storePassword keystoreProps.getProperty('storePassword')
keyAlias keystoreProps.getProperty('keyAlias').trim()
keyPassword keystoreProps.getProperty('keyPassword')
}
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseKey) signingConfig signingConfigs.cardy
}
debug {
if (hasReleaseKey) signingConfig signingConfigs.cardy
} }
} }
} }
if (!hasReleaseKey) {
logger.warn('Cardy: android/keystore.properties is missing or incomplete: debug builds use the debug key, release builds are unsigned.')
}
repositories { repositories {
flatDir{ flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capgo-capacitor-updater')
} }
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.aggtaa.cardy",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
@@ -32,6 +32,11 @@
</activity> </activity>
<!-- Results of self-updates via PackageInstaller (ApkUpdaterPlugin). -->
<receiver
android:name=".ApkInstallReceiver"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -49,4 +54,7 @@
<!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). --> <!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Self-update: install newer APKs of this app downloaded from the Cardy server. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
</manifest> </manifest>
@@ -0,0 +1,15 @@
package com.aggtaa.cardy;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/** Receives PackageInstaller results for self-updates (see ApkUpdaterPlugin). */
public class ApkInstallReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (ApkUpdaterPlugin.ACTION_INSTALL_STATUS.equals(intent.getAction())) {
ApkUpdaterPlugin.onInstallStatus(intent);
}
}
}
@@ -0,0 +1,186 @@
package com.aggtaa.cardy;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
/**
* Self-update of the whole app (outside of Play Store): downloads an APK, checks its SHA-256 and hands
* it to the system installer. Android asks the user to confirm unless it allows an update without user
* action (Android 12+, when this app installed itself last time).
*
* Events: "progress" { downloaded, total }, "status" { status: "installing" | "confirm" | "failed", message }.
* A successful install replaces and restarts the app, so there is no "done" event.
*/
@CapacitorPlugin(name = "ApkUpdater")
public class ApkUpdaterPlugin extends Plugin {
static final String ACTION_INSTALL_STATUS = "com.aggtaa.cardy.APK_INSTALL_STATUS";
private static ApkUpdaterPlugin instance;
private volatile boolean busy = false;
@Override
public void load() {
instance = this;
}
/** Whether the user allowed this app to install apps ("Install unknown apps"). */
@PluginMethod
public void canInstall(PluginCall call) {
JSObject result = new JSObject();
result.put("allowed", Build.VERSION.SDK_INT < 26 || getContext().getPackageManager().canRequestPackageInstalls());
call.resolve(result);
}
/** Opens the system screen where the user allows installing apps from Cardy. */
@PluginMethod
public void openInstallSettings(PluginCall call) {
if (Build.VERSION.SDK_INT >= 26) {
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getContext().getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
}
call.resolve();
}
/** Downloads { url } , verifies { sha256 } and starts the installation. Resolves once installation has started. */
@PluginMethod
public void downloadAndInstall(PluginCall call) {
String url = call.getString("url");
String sha256 = call.getString("sha256");
if (url == null || sha256 == null) {
call.reject("url and sha256 are required");
return;
}
if (busy) {
call.reject("An update is already in progress");
return;
}
busy = true;
new Thread(() -> {
File apk = new File(getContext().getCacheDir(), "cardy-update.apk");
try {
download(url, apk);
String actual = sha256Hex(apk);
if (!actual.equalsIgnoreCase(sha256)) throw new IOException("Checksum mismatch");
install(apk);
call.resolve();
} catch (Exception e) {
busy = false;
apk.delete();
call.reject(e.getMessage() != null ? e.getMessage() : e.toString());
}
}).start();
}
private void download(String url, File target) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(20000);
conn.setReadTimeout(30000);
try {
if (conn.getResponseCode() != 200) throw new IOException("Download failed: HTTP " + conn.getResponseCode());
long total = conn.getContentLengthLong();
try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(target)) {
byte[] buffer = new byte[64 * 1024];
long done = 0;
long lastReport = 0;
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
done += n;
if (done - lastReport >= 256 * 1024 || done == total) {
lastReport = done;
JSObject progress = new JSObject();
progress.put("downloaded", done);
progress.put("total", total);
notifyListeners("progress", progress);
}
}
}
} finally {
conn.disconnect();
}
}
private static String sha256Hex(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[64 * 1024];
int n;
while ((n = in.read(buffer)) != -1) digest.update(buffer, 0, n);
}
StringBuilder hex = new StringBuilder();
for (byte b : digest.digest()) hex.append(String.format("%02x", b));
return hex.toString();
}
private void install(File apk) throws IOException {
PackageInstaller installer = getContext().getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
params.setAppPackageName(getContext().getPackageName());
if (Build.VERSION.SDK_INT >= 31) {
// Granted only when Android allows it (this app is the installer of record); otherwise it asks.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
}
int sessionId = installer.createSession(params);
try (PackageInstaller.Session session = installer.openSession(sessionId)) {
try (InputStream in = new FileInputStream(apk); OutputStream out = session.openWrite("cardy.apk", 0, apk.length())) {
byte[] buffer = new byte[64 * 1024];
int n;
while ((n = in.read(buffer)) != -1) out.write(buffer, 0, n);
session.fsync(out);
}
Intent intent = new Intent(getContext(), ApkInstallReceiver.class).setAction(ACTION_INSTALL_STATUS);
int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0);
PendingIntent pending = PendingIntent.getBroadcast(getContext(), sessionId, intent, flags);
session.commit(pending.getIntentSender());
}
JSObject status = new JSObject();
status.put("status", "installing");
notifyListeners("status", status);
}
/** Called by ApkInstallReceiver with the installer's result. */
static void onInstallStatus(Intent intent) {
ApkUpdaterPlugin plugin = instance;
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
// Android wants the user to confirm: show its dialog.
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
if (confirm != null && plugin != null) {
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
plugin.getContext().startActivity(confirm);
JSObject result = new JSObject();
result.put("status", "confirm");
plugin.notifyListeners("status", result);
}
return;
}
if (plugin == null) return;
plugin.busy = false;
if (status != PackageInstaller.STATUS_SUCCESS) {
JSObject result = new JSObject();
result.put("status", "failed");
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
result.put("message", message != null ? message : "Installation failed (" + status + ")");
plugin.notifyListeners("status", result);
}
}
}
@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
registerPlugin(ScreenBrightnessPlugin.class); registerPlugin(ScreenBrightnessPlugin.class);
registerPlugin(ApkUpdaterPlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
} }
} }
@@ -7,3 +7,6 @@ project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capac
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.51.25_@capacitor+core@8.5.2/node_modules/@capgo/capacitor-updater/android')
@@ -0,0 +1,6 @@
# Copy to keystore.properties (same folder, not committed) and fill in.
# Use forward slashes in the path.
storeFile=D:/Documents/Security/android_keychain/cardy.jks
storePassword=
keyAlias=
keyPassword=
+24
View File
@@ -1,13 +1,37 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
// Live reload (scripts/android-dev.mjs): the app loads the Vite dev server instead of the bundled build.
// Only set while syncing for development; a normal `build:android` bundles the web build.
const devUrl = process.env.CAP_DEV_URL;
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.aggtaa.cardy', appId: 'com.aggtaa.cardy',
appName: 'Cardy', appName: 'Cardy',
webDir: 'dist-capacitor', webDir: 'dist-capacitor',
backgroundColor: '#0f1115', backgroundColor: '#0f1115',
// No Capacitor logging even in debug builds: it writes plugin results (e.g. the stored access key
// read from Preferences) and the JS console to logcat. Debug JS via chrome://inspect instead.
loggingBehavior: 'none',
android: { android: {
backgroundColor: '#0f1115', backgroundColor: '#0f1115',
}, },
plugins: {
// Live updates in manual mode (src/updater.ts): the app decides when to check, download and apply.
CapacitorUpdater: {
autoUpdate: 'off',
// Never talk to Capgo's cloud: no stats/telemetry, no update or channel requests.
statsUrl: '',
updateUrl: '',
channelUrl: '',
// A new bundle must call notifyAppReady() within this time, otherwise it is rolled back.
appReadyTimeout: 10000,
// A newly installed APK starts from its own built-in bundle.
resetWhenUpdate: true,
autoDeleteFailed: true,
autoDeletePrevious: true,
},
},
...(devUrl ? { server: { url: devUrl, cleartext: true } } : {}),
}; };
export default config; export default config;
+5
View File
@@ -9,6 +9,9 @@
"preview": "vite preview", "preview": "vite preview",
"start": "vite build && vite preview", "start": "vite build && vite preview",
"build:android": "vite build --mode capacitor && cap sync android", "build:android": "vite build --mode capacitor && cap sync android",
"android:dev": "node scripts/android-dev.mjs",
"publish:android": "node scripts/publish-android-update.mjs",
"publish:android-apk": "node scripts/publish-android-apk.mjs",
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "vitest run" "test": "vitest run"
}, },
@@ -17,6 +20,7 @@
"@capacitor/app": "^8.1.1", "@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2", "@capacitor/core": "^8.5.2",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.25",
"@cardy/shared": "workspace:*", "@cardy/shared": "workspace:*",
"barcode-detector": "^3.2.2", "barcode-detector": "^3.2.2",
"bwip-js": "^4.11.4", "bwip-js": "^4.11.4",
@@ -30,6 +34,7 @@
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"fflate": "^0.8.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.3.1", "vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
+39
View File
@@ -0,0 +1,39 @@
// Live reload on an Android device/emulator connected via adb.
//
// 1. `pnpm dev` in mobile-client must be running (Vite on PORT, default 10003).
// 2. adb reverse makes http://localhost:PORT on the phone reach the dev server on this computer.
// localhost counts as a secure context, so the camera and crypto.subtle (PIN) keep working.
// 3. `cap run android` syncs a config pointing the WebView at the dev server, builds and installs the app.
// Extra arguments are passed to `cap run` (e.g. --target <deviceId>).
//
// Afterwards run `pnpm build:android` before building an APK to share: it bundles the web build again.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
const port = process.env.PORT || '10003';
const devUrl = `http://localhost:${port}/m/`;
function findAdb() {
const exe = process.platform === 'win32' ? 'adb.exe' : 'adb';
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || (process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk'));
const fromSdk = sdk && path.join(sdk, 'platform-tools', exe);
return fromSdk && existsSync(fromSdk) ? fromSdk : exe;
}
const adb = findAdb();
const reverse = spawnSync(adb, ['reverse', `tcp:${port}`, `tcp:${port}`], { stdio: 'inherit' });
if (reverse.status !== 0) {
console.error(`\n"${adb} reverse" failed: connect a device with USB debugging (or start an emulator) and make sure adb is installed.`);
process.exit(1);
}
console.log(`adb reverse tcp:${port} -> this computer. The app will load ${devUrl}\n`);
const run = spawnSync('pnpm', ['exec', 'cap', 'run', 'android', ...process.argv.slice(2)], {
stdio: 'inherit',
shell: true,
env: { ...process.env, CAP_DEV_URL: devUrl },
});
console.log('\nReminder: the installed app now loads the dev server. Run `pnpm build:android` before building an APK to share.');
process.exit(run.status ?? 1);
@@ -0,0 +1,92 @@
// Publishes a new APK for in-app self-update (use it whenever the native part changes: plugins,
// AndroidManifest, Java code; web-only changes go through publish-android-update.mjs):
// 1. builds the Capacitor web bundle and syncs it into the Android project,
// 2. builds a release APK signed with the key from android/keystore.properties,
// versionCode = build time (yyDDDHHmm, UTC), versionName = web build version,
// 3. copies it to updates/android/cardy-<versionCode>.apk and writes updates/android/apk.json.
// Web updates published afterwards require this APK (see minNativeBuild in publish-android-update.mjs).
//
// Needs a JDK (JAVA_HOME, or Android Studio's bundled one) and the Android SDK (android/local.properties).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const root = path.resolve(import.meta.dirname, '..');
const androidDir = path.join(root, 'android');
const outDir = path.join(root, 'updates', 'android');
const KEEP_APKS = 2;
function fail(message) {
console.error(`\n${message}`);
process.exit(1);
}
// ---- preconditions ----
const keystoreProps = path.join(androidDir, 'keystore.properties');
if (!existsSync(keystoreProps)) fail(`Missing ${keystoreProps}: copy keystore.properties.example and fill it in.`);
const props = Object.fromEntries(
readFileSync(keystoreProps, 'utf8')
.split(/\r?\n/)
.filter((l) => l.includes('=') && !l.trimStart().startsWith('#'))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
);
for (const key of ['storeFile', 'storePassword', 'keyAlias', 'keyPassword']) {
if (!props[key]) fail(`${keystoreProps}: ${key} is empty.`);
}
if (!existsSync(props.storeFile)) fail(`Keystore not found: ${props.storeFile}`);
const env = { ...process.env };
if (!env.JAVA_HOME) {
const studioJbr = 'C:/Program Files/Android/Android Studio/jbr';
if (existsSync(studioJbr)) env.JAVA_HOME = studioJbr;
else fail('Set JAVA_HOME to a JDK (e.g. Android Studio\'s "jbr" folder).');
}
// ---- version ----
const now = new Date();
const dayOfYear = Math.floor((Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - Date.UTC(now.getUTCFullYear(), 0, 0)) / 86_400_000);
const pad = (n, w) => String(n).padStart(w, '0');
const versionCode = Number(
`${pad(now.getUTCFullYear() % 100, 2)}${pad(dayOfYear, 3)}${pad(now.getUTCHours(), 2)}${pad(now.getUTCMinutes(), 2)}`,
);
// ---- build ----
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
execSync('pnpm exec cap sync android', { cwd: root, stdio: 'inherit' });
const { version: versionName } = JSON.parse(readFileSync(path.join(root, 'dist-capacitor', 'cardy-version.json'), 'utf8'));
const gradlew = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
execSync(`${gradlew} assembleRelease -PcardyVersionCode=${versionCode} -PcardyVersionName=${versionName}`, {
cwd: androidDir,
stdio: 'inherit',
env,
});
const built = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
if (!existsSync(built)) fail(`Signed APK not found at ${built} (is the signing configured?).`);
// ---- publish ----
mkdirSync(outDir, { recursive: true });
const apkName = `cardy-${versionCode}.apk`;
copyFileSync(built, path.join(outDir, apkName));
const data = readFileSync(path.join(outDir, apkName));
const sha256 = createHash('sha256').update(data).digest('hex');
// apk.json last: clients never see an APK that is not there yet.
writeFileSync(
path.join(outDir, 'apk.json'),
JSON.stringify({ versionCode, versionName, url: apkName, sha256, size: data.length, publishedAt: now.toISOString() }, null, 2),
);
const apks = readdirSync(outDir)
.filter((f) => /^cardy-\d+\.apk$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of apks.slice(KEEP_APKS)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished APK ${versionName} (versionCode ${versionCode}, ${(data.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(` ${path.join(outDir, apkName)}`);
console.log('Served at /m/android/apk.json; web updates published from now on require this APK.');
@@ -0,0 +1,64 @@
// Publishes a live update of the Android app's web part:
// 1. builds the Capacitor web bundle (its version comes from the build time),
// 2. zips it into updates/android/bundle-<version>.zip,
// 3. writes updates/android/version.json (version, zip, SHA-256, minimum native build).
// The mobile-client dev/preview server serves updates/android at /m/android/, i.e.
// https://dev.aggtaa.com/m/android/version.json. Installed apps pick the update up on the next check.
//
// minNativeBuild is the versionCode of the latest published APK (updates/android/apk.json, see
// publish-android-apk.mjs): after an APK release, web updates go only to that APK or newer, older
// installations are offered the APK instead. Publish an APK whenever the native part changes
// (plugins, AndroidManifest, Java code).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { zipSync } from 'fflate';
const root = path.resolve(import.meta.dirname, '..');
const distDir = path.join(root, 'dist-capacitor');
const outDir = path.join(root, 'updates', 'android');
const KEEP_BUNDLES = 3;
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
const { version } = JSON.parse(readFileSync(path.join(distDir, 'cardy-version.json'), 'utf8'));
function collect(dir, prefix = '') {
const files = {};
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
const rel = prefix ? `${prefix}/${name}` : name;
if (statSync(full).isDirectory()) Object.assign(files, collect(full, rel));
else files[rel] = readFileSync(full);
}
return files;
}
const zip = Buffer.from(zipSync(collect(distDir), { level: 6 }));
const checksum = createHash('sha256').update(zip).digest('hex');
let minNativeBuild = 1;
try {
minNativeBuild = Number(JSON.parse(readFileSync(path.join(outDir, 'apk.json'), 'utf8')).versionCode) || 1;
} catch {
// no APK published yet: any installed APK can take the bundle
}
mkdirSync(outDir, { recursive: true });
const zipName = `bundle-${version}.zip`;
writeFileSync(path.join(outDir, zipName), zip);
// version.json last: clients never see a version whose zip is not there yet.
writeFileSync(
path.join(outDir, 'version.json'),
JSON.stringify({ version, url: zipName, checksum, minNativeBuild, publishedAt: new Date().toISOString() }, null, 2),
);
// Keep a few recent bundles (clients may be mid-download of the previous one).
const bundles = readdirSync(outDir)
.filter((f) => /^bundle-.+\.zip$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of bundles.slice(KEEP_BUNDLES)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished Android update ${version} (${(zip.length / 1024).toFixed(0)} KiB, native build >= ${minNativeBuild})`);
console.log(` ${path.join(outDir, zipName)}`);
console.log('Served at /m/android/version.json by the mobile-client dev/preview server.');
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { apkInstaller, type PublishedApk } from './updater.js';
import { ConfirmDialog } from './ui.js';
type State =
| { step: 'idle' }
| { step: 'permission' }
| { step: 'downloading'; downloaded: number; total: number }
| { step: 'installing' }
| { step: 'confirm' }
| { step: 'error'; message: string };
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
/** "App update available" on the card list: downloads the APK and hands it to Android's installer. */
export function ApkUpdateBanner({ apk }: { apk: PublishedApk }) {
const [state, setState] = useState<State>({ step: 'idle' });
const install = async () => {
try {
if (!(await apkInstaller.canInstall())) {
setState({ step: 'permission' });
return;
}
setState({ step: 'downloading', downloaded: 0, total: apk.size });
await apkInstaller.install(apk, (e) => {
if (e.kind === 'progress') setState({ step: 'downloading', downloaded: e.downloaded, total: e.total > 0 ? e.total : apk.size });
else if (e.status === 'installing') setState({ step: 'installing' });
else if (e.status === 'confirm') setState({ step: 'confirm' });
else setState({ step: 'error', message: e.message ?? 'Installation failed' });
});
} catch (e) {
setState({ step: 'error', message: (e as Error).message });
}
};
let text: string;
let action: string | undefined = 'Install';
switch (state.step) {
case 'downloading':
text = `Downloading the update… ${mb(state.downloaded)} / ${mb(state.total)} MB`;
action = undefined;
break;
case 'installing':
text = 'Installing… the app will restart.';
action = undefined;
break;
case 'confirm':
text = 'Confirm the installation in the Android dialog.';
action = 'Retry';
break;
case 'error':
text = `Update failed: ${state.message}`;
action = 'Retry';
break;
default:
text = `A new version of the app is available (${apk.versionName}, ${mb(apk.size)} MB).`;
}
return (
<>
<div className="banner info update-banner" role="status">
<span>{text}</span>
{action && (
<button className="text-button strong" onClick={install}>
{action}
</button>
)}
</div>
{state.step === 'permission' && (
<ConfirmDialog
title="Allow app updates"
message="Android needs your permission for Cardy to install its own updates. Turn on “Allow from this source” on the next screen, come back and tap Install again."
confirmLabel="Open settings"
onCancel={() => setState({ step: 'idle' })}
onConfirm={() => {
setState({ step: 'idle' });
void apkInstaller.openInstallSettings();
}}
/>
)}
</>
);
}
+47 -3
View File
@@ -5,6 +5,14 @@ import { useNavigation, type LoginPrefill } from './navigation.js';
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js'; import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js'; import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
import { refreshCardList, syncImages } from './sync.js'; import { refreshCardList, syncImages } from './sync.js';
import {
applyPendingUpdate,
checkForApkUpdate,
checkForUpdate,
consumeUpdateReload,
markAppReady,
type PublishedApk,
} from './updater.js';
import { CardScreen } from './screens/CardScreen.js'; import { CardScreen } from './screens/CardScreen.js';
import { CardsScreen, type SyncState } from './screens/CardsScreen.js'; import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
import { EditScreen } from './screens/EditScreen.js'; import { EditScreen } from './screens/EditScreen.js';
@@ -29,13 +37,17 @@ export function App() {
); );
}, []); }, []);
if (!boot) return <div className="splash" />; if (!boot) return <div className="splash" />;
return <Main boot={boot} />; return <Main boot={boot} justUpdated={JUST_UPDATED} />;
} }
function Main({ boot }: { boot: Boot }) { /** Read once per page load (render functions may run twice). */
const JUST_UPDATED = consumeUpdateReload();
function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
const [session, setSession] = useState(boot.session); const [session, setSession] = useState(boot.session);
const [pin, setPin] = useState(boot.pin); const [pin, setPin] = useState(boot.pin);
const [locked, setLocked] = useState(!!boot.pin); // Not locked after a reload that applied an update: the user was unlocked a moment ago.
const [locked, setLocked] = useState(() => !!boot.pin && !justUpdated);
const [cards, setCards] = useState(boot.cards); const [cards, setCards] = useState(boot.cards);
const [usage, setUsage] = useState(boot.usage); const [usage, setUsage] = useState(boot.usage);
const [imageEpoch, setImageEpoch] = useState(0); const [imageEpoch, setImageEpoch] = useState(0);
@@ -184,6 +196,37 @@ function Main({ boot }: { boot: Boot }) {
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]); useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
// ---- live updates (Android app only) ----
// The app rendered: confirm this bundle works (a bundle that never gets here is rolled back).
useEffect(() => void markAppReady(), []);
// Reloading is safe only on the unlocked card list with no download running: never while a card is
// shown at the till, while editing, or on the lock screen (the reload skips the PIN).
const safeToReload = !locked && !lockedLogin && route?.name === 'cards' && !sync.busy;
const safeRef = useRef(safeToReload);
safeRef.current = safeToReload;
// A newer APK (native part changed) is offered on the card list; installing it needs the user's OK.
const [apkUpdate, setApkUpdate] = useState<PublishedApk>();
const lastApkCheck = useRef(0);
useEffect(() => {
const check = () => {
void checkForUpdate(() => safeRef.current);
if (Date.now() - lastApkCheck.current >= 60_000) {
lastApkCheck.current = Date.now();
void checkForApkUpdate().then(setApkUpdate);
}
};
check();
return onResumeAfter(0, check);
}, []);
useEffect(() => {
if (safeToReload) void applyPendingUpdate();
}, [safeToReload]);
// Android back button = history back; on the lock screen and at the root it exits the app. // Android back button = history back; on the lock screen and at the root it exits the app.
const lockedRef = useRef({ locked, lockedLogin }); const lockedRef = useRef({ locked, lockedLogin });
lockedRef.current = { locked, lockedLogin }; lockedRef.current = { locked, lockedLogin };
@@ -261,6 +304,7 @@ function Main({ boot }: { boot: Boot }) {
imageEpoch={imageEpoch} imageEpoch={imageEpoch}
sync={sync} sync={sync}
session={session} session={session}
apkUpdate={apkUpdate}
onDismissSync={() => setSync({ busy: false })} onDismissSync={() => setSync({ busy: false })}
onRefresh={refresh} onRefresh={refresh}
onOpen={(id) => push({ name: 'card', id })} onOpen={(id) => push({ name: 'card', id })}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import { PIN_LENGTH } from './pin.js'; import { PIN_LENGTH } from './pin.js';
import { Icon } from './ui.js'; import { Icon, VersionLabel } from './ui.js';
/** /**
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too). * Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
@@ -81,6 +81,7 @@ export function PinPad({
</button> </button>
</div> </div>
<div className="pin-footer">{footer}</div> <div className="pin-footer">{footer}</div>
<VersionLabel />
</div> </div>
); );
} }
+20
View File
@@ -0,0 +1,20 @@
/**
* Version of a web build, derived from the build time (UTC): "YYYY.MDD.Hmmss", e.g. 2026.929.151230
* for 2026-09-29 15:12:30. Valid semver (no leading zeros) and ordered like the build time.
*/
export function buildVersionFromDate(date: Date): string {
const md = (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
const hms = date.getUTCHours() * 10000 + date.getUTCMinutes() * 100 + date.getUTCSeconds();
return `${date.getUTCFullYear()}.${md}.${hms}`;
}
/** Numeric comparison of dot-separated versions; non-numeric parts count as 0. */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((p) => Number.parseInt(p, 10) || 0);
const pb = b.split('.').map((p) => Number.parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return Math.sign(diff);
}
return 0;
}
+4
View File
@@ -0,0 +1,4 @@
/** Build version, "YYYY.MDD.Hmmss" from the build time (UTC); "dev" on the dev server. */
declare const __BUILD_VERSION__: string;
/** Base URL of Android live updates (version.json and bundle zips), without a trailing slash. */
declare const __UPDATE_URL__: string;
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Card } from '@cardy/shared'; import type { Card } from '@cardy/shared';
import { normalizeServerUrl } from './api.js'; import { normalizeServerUrl } from './api.js';
import { buildVersionFromDate, compareVersions } from './buildVersion.js';
import { detectedFormatToCodeType } from './codeFormats.js'; import { detectedFormatToCodeType } from './codeFormats.js';
import { checkPin, createPinRecord } from './pin.js'; import { checkPin, createPinRecord } from './pin.js';
import { cardsNeedingImages, runPool, sortCards } from './sync.js'; import { cardsNeedingImages, runPool, sortCards } from './sync.js';
@@ -12,6 +13,7 @@ const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
code: '1', code: '1',
codeType: 'QR', codeType: 'QR',
comment: '', comment: '',
tags: [],
logo: null, logo: null,
images: [], images: [],
imagesVersion: 0, imagesVersion: 0,
@@ -78,6 +80,21 @@ describe('PIN', () => {
}); });
}); });
describe('build versions', () => {
it('derive a semver-compatible version from the build time (UTC)', () => {
expect(buildVersionFromDate(new Date('2026-09-29T15:12:30Z'))).toBe('2026.929.151230');
expect(buildVersionFromDate(new Date('2026-01-05T00:00:07Z'))).toBe('2026.105.7');
});
it('orders versions like build times', () => {
const times = ['2026-01-05T00:00:07Z', '2026-01-05T09:59:59Z', '2026-01-05T10:00:00Z', '2026-09-29T15:12:30Z', '2026-10-01T00:00:00Z', '2027-01-01T00:00:00Z'];
const versions = times.map((t) => buildVersionFromDate(new Date(t)));
for (let i = 1; i < versions.length; i++) expect(compareVersions(versions[i]!, versions[i - 1]!)).toBe(1);
expect(compareVersions('2026.929.151230', '2026.929.151230')).toBe(0);
expect(compareVersions('2026.929.1', 'dev')).toBe(1);
});
});
describe('helpers', () => { describe('helpers', () => {
it('normalizes the server address', () => { it('normalizes the server address', () => {
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api'); expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
+2 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared'; import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
import { setMaxBrightness } from '../platform.js'; import { setMaxBrightness } from '../platform.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js'; import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, TagList, useStoredImage } from '../ui.js';
export function CardScreen({ export function CardScreen({
card, card,
@@ -64,6 +64,7 @@ export function CardScreen({
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span> <span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
</div> </div>
{card.tags?.length > 0 && <TagList tags={card.tags} />}
{card.comment && <p className="card-comment">{card.comment}</p>} {card.comment && <p className="card-comment">{card.comment}</p>}
{card.images.length > 0 && ( {card.images.length > 0 && (
+15 -9
View File
@@ -1,8 +1,10 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { textColorOn, type Card } from '@cardy/shared'; import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
import type { Session, UsageMap } from '../storage.js'; import type { Session, UsageMap } from '../storage.js';
import type { PublishedApk } from '../updater.js';
import { sortCards } from '../sync.js'; import { sortCards } from '../sync.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js'; import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export interface SyncState { export interface SyncState {
busy: boolean; busy: boolean;
@@ -17,6 +19,7 @@ export function CardsScreen({
imageEpoch, imageEpoch,
sync, sync,
session, session,
apkUpdate,
onDismissSync, onDismissSync,
onRefresh, onRefresh,
onOpen, onOpen,
@@ -28,6 +31,7 @@ export function CardsScreen({
imageEpoch: number; imageEpoch: number;
sync: SyncState; sync: SyncState;
session: Session; session: Session;
apkUpdate?: PublishedApk;
onDismissSync: () => void; onDismissSync: () => void;
onRefresh: () => void; onRefresh: () => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
@@ -38,14 +42,12 @@ export function CardsScreen({
const [confirmKey, setConfirmKey] = useState(false); const [confirmKey, setConfirmKey] = useState(false);
const filterInput = useRef<HTMLInputElement>(null); const filterInput = useRef<HTMLInputElement>(null);
const visible = useMemo(() => { // Search by name, tags and comment.
const q = filter.trim().toLowerCase(); const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
const sorted = sortCards(cards, usage);
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
}, [cards, usage, filter]);
return ( return (
<Screen <Screen
className="cards-screen"
title="Cards" title="Cards"
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />} left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />} right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
@@ -59,8 +61,8 @@ export function CardsScreen({
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
spellCheck={false} spellCheck={false}
placeholder="Filter by name" placeholder="Search by name, tag or comment"
aria-label="Filter by name" aria-label="Search by name, tag or comment"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
/> />
@@ -78,6 +80,8 @@ export function CardsScreen({
)} )}
</div> </div>
{apkUpdate && <ApkUpdateBanner apk={apkUpdate} />}
{sync.busy && sync.progress && sync.progress.total > 0 && ( {sync.busy && sync.progress && sync.progress.total > 0 && (
<Banner kind="info"> <Banner kind="info">
Downloading images {sync.progress.done}/{sync.progress.total} Downloading images {sync.progress.done}/{sync.progress.total}
@@ -108,6 +112,8 @@ export function CardsScreen({
</div> </div>
)} )}
<VersionLabel />
<button className="fab" onClick={onAdd} aria-label="Add card"> <button className="fab" onClick={onAdd} aria-label="Add card">
<Icon name="plus" size={28} /> <Icon name="plus" size={28} />
</button> </button>
+21 -3
View File
@@ -4,6 +4,8 @@ import {
CODE_TYPES, CODE_TYPES,
CODE_TYPE_LABELS, CODE_TYPE_LABELS,
cardFieldsSchema, cardFieldsSchema,
formatTags,
parseTags,
randomCardColor, randomCardColor,
type Card, type Card,
type CardFields, type CardFields,
@@ -14,7 +16,7 @@ import { ApiError, type Api } from '../api.js';
import { compressImage, fileNameFor } from '../imageTools.js'; import { compressImage, fileNameFor } from '../imageTools.js';
import { ScannerView, scanImageFile } from '../ScannerView.js'; import { ScannerView, scanImageFile } from '../ScannerView.js';
import { images } from '../storage.js'; import { images } from '../storage.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js'; import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob }; type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
interface NewImage { interface NewImage {
@@ -45,6 +47,8 @@ export function EditScreen({
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' } ? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' }, : { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
); );
// Edited as typed ("a, b"); parsed into the tag list on save.
const [tagsText, setTagsText] = useState(formatTags(card?.tags));
// Set once the card exists on the server (also after a partially failed first save). // Set once the card exists on the server (also after a partially failed first save).
const [savedId, setSavedId] = useState(card?.id); const [savedId, setSavedId] = useState(card?.id);
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null); const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
@@ -93,7 +97,7 @@ export function EditScreen({
}); });
const save = async () => { const save = async () => {
const parsed = cardFieldsSchema.safeParse(fields); const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
if (!parsed.success) { if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join('. ')); setError(parsed.error.issues.map((i) => i.message).join('. '));
return; return;
@@ -108,7 +112,9 @@ export function EditScreen({
let addedDone = false; let addedDone = false;
try { try {
const form = new FormData(); const form = new FormData();
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v); for (const [k, v] of Object.entries(parsed.data)) {
if (v !== undefined) form.append(k, Array.isArray(v) ? v.join(',') : v);
}
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo')); if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true'); if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form); current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
@@ -272,6 +278,18 @@ export function EditScreen({
/> />
</label> </label>
<label className="field">
<span>Tags</span>
<input
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
placeholder="Comma-separated, e.g. food, pharmacy"
autoCapitalize="off"
aria-label="Tags"
/>
{parseTags(tagsText).length > 0 && <TagList tags={parseTags(tagsText)} />}
</label>
<div className="field"> <div className="field">
<span>Photos</span> <span>Photos</span>
<div className="photo-grid"> <div className="photo-grid">
+2 -1
View File
@@ -5,7 +5,7 @@ import type { LoginPrefill } from '../navigation.js';
import { defaultServerUrl } from '../platform.js'; import { defaultServerUrl } from '../platform.js';
import { ScannerView } from '../ScannerView.js'; import { ScannerView } from '../ScannerView.js';
import type { Session } from '../storage.js'; import type { Session } from '../storage.js';
import { Banner, Icon, IconButton, Screen } from '../ui.js'; import { Banner, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export type { LoginPrefill }; export type { LoginPrefill };
@@ -136,6 +136,7 @@ export function LoginScreen({
{busy ? 'Checking…' : 'Sign in'} {busy ? 'Checking…' : 'Sign in'}
</button> </button>
</form> </form>
<VersionLabel />
</Screen> </Screen>
); );
} }
+72
View File
@@ -112,6 +112,29 @@ textarea:focus {
border-color: var(--accent); border-color: var(--accent);
} }
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.tag {
padding: 3px 10px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
overflow-wrap: anywhere;
}
.card-screen .tags {
margin: 0 0 14px;
}
.card-comment { .card-comment {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -275,6 +298,15 @@ textarea:focus {
color: var(--accent); color: var(--accent);
} }
.update-banner {
align-items: center;
}
.update-banner .text-button {
padding: 4px 8px;
white-space: nowrap;
}
.banner-close { .banner-close {
background: none; background: none;
border: none; border: none;
@@ -291,6 +323,8 @@ textarea:focus {
/* ---- login ---- */ /* ---- login ---- */
.login .screen-body { .login .screen-body {
/* No floating button here: the version label sits at the very bottom. */
padding-bottom: calc(12px + var(--safe-bottom));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
@@ -413,6 +447,44 @@ textarea:focus {
min-height: 44px; min-height: 44px;
} }
/* Pinned to the bottom of the login and PIN screens. */
.version-label {
margin-top: auto;
padding-top: 16px;
text-align: center;
color: var(--muted);
font-size: 0.75rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* Card list: the label is the footer below the tiles, level with the floating button. */
.cards-screen .screen-body {
display: flex;
flex-direction: column;
padding-bottom: calc(40px + var(--safe-bottom));
}
.cards-screen .version-label {
padding-top: 48px;
padding-right: 72px;
padding-left: 72px;
}
/* The PIN screen is centered vertically: keep the label out of the flow. */
.pin {
position: relative;
}
.pin .version-label {
position: absolute;
left: 0;
right: 0;
bottom: calc(10px + var(--safe-bottom));
margin: 0;
padding: 0;
}
/* ---- cards list ---- */ /* ---- cards list ---- */
.filter { .filter {
+17
View File
@@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon:
); );
} }
export function TagList({ tags }: { tags: readonly string[] }) {
return (
<ul className="tags" aria-label="Tags">
{tags.map((tag) => (
<li key={tag} className="tag">
{tag}
</li>
))}
</ul>
);
}
/** Version of the running web build (built-in or live-updated bundle). */
export function VersionLabel() {
return <div className="version-label">Version {__BUILD_VERSION__}</div>;
}
// ---- layout ---- // ---- layout ----
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) { export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
+183
View File
@@ -0,0 +1,183 @@
import { App } from '@capacitor/app';
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { CapacitorUpdater } from '@capgo/capacitor-updater';
import { compareVersions } from './buildVersion.js';
import { isNative } from './platform.js';
/**
* Live updates of the Android app's web part (Capgo updater in manual mode, own server):
* the app always runs a local bundle (built-in or downloaded), so everything keeps working offline.
* When dev.aggtaa.com is reachable and publishes a newer version, the bundle is downloaded in the
* background and applied right away on a safe screen, otherwise via next() (on backgrounding).
*/
export const BUILD_VERSION = __BUILD_VERSION__;
const UPDATE_URL = __UPDATE_URL__;
const CHECK_TIMEOUT_MS = 10_000;
/** Do not check more often than this (launch + every return to the foreground). */
const MIN_CHECK_INTERVAL_MS = 60_000;
/** Set right before an immediate reload so the fresh bundle does not ask for the PIN again. */
const RELOAD_MARK = 'cardy:update-reload';
interface PublishedVersion {
version: string;
/** Bundle zip, relative to version.json or absolute. */
url: string;
/** SHA-256 of the zip, hex. */
checksum: string;
/** Lowest native build (android versionCode) the bundle works with. */
minNativeBuild?: number;
}
// Imported statically on purpose: a Capacitor plugin object is a Proxy that answers any property,
// including `then`, so it must never be the resolved value of a Promise (the Promise would call
// CapacitorUpdater.then() -> "not implemented on android").
const updater = CapacitorUpdater;
let checking = false;
let lastCheck = 0;
/** Downloaded bundle waiting for a safe moment (also queued with next()). */
let pendingBundleId: string | undefined;
/** Must run early on every start: otherwise the updater rolls back to the previous bundle. */
export async function markAppReady(): Promise<void> {
if (!isNative) return;
try {
await updater.notifyAppReady();
} catch (e) {
console.warn('notifyAppReady failed', e);
}
}
/** True once, right after this app reloaded itself to apply an update. */
export function consumeUpdateReload(): boolean {
try {
const at = Number(localStorage.getItem(RELOAD_MARK) ?? 0);
localStorage.removeItem(RELOAD_MARK);
return at > 0 && Date.now() - at < 60_000;
} catch {
return false;
}
}
/**
* Checks the server for a newer bundle and downloads it. Applies it immediately when
* `canApplyNow()` says so, otherwise queues it for the next backgrounding.
*/
export async function checkForUpdate(canApplyNow: () => boolean): Promise<void> {
if (!isNative || BUILD_VERSION === 'dev' || checking) return;
if (Date.now() - lastCheck < MIN_CHECK_INTERVAL_MS) return;
checking = true;
lastCheck = Date.now();
try {
const res = await fetch(`${UPDATE_URL}/version.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return;
const published = (await res.json()) as PublishedVersion;
if (!published?.version || !published.url || compareVersions(published.version, BUILD_VERSION) <= 0) return;
const nativeBuild = Number((await App.getInfo()).build) || 0;
if (published.minNativeBuild && nativeBuild < published.minNativeBuild) {
console.info(`Update ${published.version} needs a newer app (build ${published.minNativeBuild}, installed ${nativeBuild})`);
return;
}
const { bundles } = await updater.list();
let bundle = bundles.find((b) => b.version === published.version && b.status !== 'error' && b.status !== 'downloading');
bundle ??= await updater.download({
url: new URL(published.url, `${UPDATE_URL}/`).href,
version: published.version,
checksum: published.checksum,
});
if (canApplyNow()) await applyNow(bundle.id);
else {
await updater.next({ id: bundle.id });
pendingBundleId = bundle.id;
}
} catch (e) {
// Offline, server down, bad bundle: keep running the current version.
console.warn('Update check failed', e);
} finally {
checking = false;
}
}
// ---- APK self-update (native part changed: plugins, manifest, Java) ----
export interface PublishedApk {
versionCode: number;
versionName: string;
/** APK file, relative to apk.json or absolute. */
url: string;
sha256: string;
size: number;
}
export type ApkInstallEvent =
| { kind: 'progress'; downloaded: number; total: number }
| { kind: 'status'; status: 'installing' | 'confirm' | 'failed'; message?: string };
interface ApkUpdaterPlugin {
canInstall(): Promise<{ allowed: boolean }>;
openInstallSettings(): Promise<void>;
downloadAndInstall(options: { url: string; sha256: string }): Promise<void>;
addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise<PluginListenerHandle>;
addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise<PluginListenerHandle>;
}
/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */
const ApkUpdater = registerPlugin<ApkUpdaterPlugin>('ApkUpdater');
/** A newer APK than the installed one, if the server publishes it. */
export async function checkForApkUpdate(): Promise<PublishedApk | undefined> {
if (!isNative || BUILD_VERSION === 'dev') return undefined;
try {
const res = await fetch(`${UPDATE_URL}/apk.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return undefined;
const apk = (await res.json()) as PublishedApk;
const installed = Number((await App.getInfo()).build) || 0;
return apk?.versionCode > installed && apk.url && apk.sha256 ? apk : undefined;
} catch {
return undefined;
}
}
export const apkInstaller = {
canInstall: async () => (await ApkUpdater.canInstall()).allowed,
openInstallSettings: () => ApkUpdater.openInstallSettings(),
/** Downloads and hands the APK to Android; `onEvent` reports progress and the installer's status. */
async install(apk: PublishedApk, onEvent: (e: ApkInstallEvent) => void): Promise<void> {
const handles: PluginListenerHandle[] = [];
const cleanup = () => void Promise.all(handles.splice(0).map((h) => h.remove()));
handles.push(
await ApkUpdater.addListener('progress', (e) => onEvent({ kind: 'progress', ...e })),
await ApkUpdater.addListener('status', (e) => {
onEvent({ kind: 'status', ...e });
if (e.status === 'failed') cleanup();
}),
);
try {
await ApkUpdater.downloadAndInstall({ url: new URL(apk.url, `${UPDATE_URL}/`).href, sha256: apk.sha256 });
} catch (e) {
cleanup();
throw e;
}
// Listeners stay until the installer reports "failed"; a successful install restarts the app.
},
};
/** Applies a queued update now (call when the app reaches a safe screen). */
export async function applyPendingUpdate(): Promise<void> {
if (pendingBundleId) await applyNow(pendingBundleId);
}
async function applyNow(id: string): Promise<void> {
try {
localStorage.setItem(RELOAD_MARK, String(Date.now()));
} catch {
// the PIN will simply be asked again
}
// Reloads the WebView from the new bundle; nothing after this runs.
await updater.set({ id });
}
+75 -15
View File
@@ -1,39 +1,94 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import type { IncomingMessage, ServerResponse } from 'node:http';
import path from 'node:path';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite'; import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { buildVersionFromDate } from './src/buildVersion.js';
// Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix). // Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix).
// Capacitor build (--mode capacitor): served from the app bundle root. // Capacitor build (--mode capacitor): served from the app bundle root.
// //
// Env (mobile-client/.env or process env): // Env (mobile-client/.env or process env):
// PORT dev/preview server port (default 10003) // PORT dev/preview server port (default 10003)
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com) // ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002) // BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002)
// CARDY_UPDATE_URL where the Android app looks for live updates (default https://dev.aggtaa.com/m/android)
// CARDY_BUILD_VERSION override the build version (default: from the build time)
const WEB_BASE = '/m/'; const WEB_BASE = '/m/';
/** Published Android updates (scripts/publish-android-update.mjs, publish-android-apk.mjs), served at /m/android/. */
const UPDATES_DIR = path.resolve('updates/android');
const UPDATES_PATH = `${WEB_BASE}android/`;
type Next = () => void;
/** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */ /** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */
function restoreStrippedBase(): Plugin { function restoreStrippedBase(req: IncomingMessage, _res: ServerResponse, next: Next) {
const rewrite = (req: { url?: string }, _res: unknown, next: () => void) => { const url = req.url ?? '/';
const url = req.url ?? '/'; if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url;
if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url; next();
next(); }
/** version.json and bundle zips for the Android app; CORS is open because the app runs on https://localhost. */
function serveAndroidUpdates(req: IncomingMessage, res: ServerResponse, next: Next) {
const url = (req.url ?? '').split('?')[0] ?? '';
if (!url.startsWith(UPDATES_PATH) || (req.method !== 'GET' && req.method !== 'HEAD')) return next();
const name = decodeURIComponent(url.slice(UPDATES_PATH.length));
const file = path.join(UPDATES_DIR, name);
res.setHeader('Access-Control-Allow-Origin', '*');
if (!/^[A-Za-z0-9._-]+$/.test(name) || !existsSync(file)) {
res.statusCode = 404;
res.end();
return;
}
const isJson = name.endsWith('.json');
res.setHeader(
'Content-Type',
isJson ? 'application/json' : name.endsWith('.apk') ? 'application/vnd.android.package-archive' : 'application/zip',
);
if (!isJson) res.setHeader('Content-Length', String(statSync(file).size));
// Bundle files are named by version and never change.
res.setHeader('Cache-Control', isJson ? 'no-cache' : 'public, max-age=31536000, immutable');
if (req.method === 'HEAD') {
res.end();
return;
}
createReadStream(file).pipe(res);
}
function cardyServer(): Plugin {
const install = (use: (handler: (req: IncomingMessage, res: ServerResponse, next: Next) => void) => void) => {
use(restoreStrippedBase);
use(serveAndroidUpdates);
}; };
return { return {
name: 'cardy-restore-stripped-base', name: 'cardy-server',
configureServer: (server) => void server.middlewares.use(rewrite), configureServer: (server) => install((h) => server.middlewares.use(h)),
configurePreviewServer: (server) => void server.middlewares.use(rewrite), configurePreviewServer: (server) => install((h) => server.middlewares.use(h)),
}; };
} }
export default defineConfig(({ mode }) => { /** Writes cardy-version.json next to the build: read by the update publishing script. */
function emitVersionFile(version: string): Plugin {
return {
name: 'cardy-version-file',
apply: 'build',
generateBundle() {
this.emitFile({ type: 'asset', fileName: 'cardy-version.json', source: JSON.stringify({ version }) });
},
};
}
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), ''); const env = loadEnv(mode, process.cwd(), '');
const capacitor = mode === 'capacitor'; const capacitor = mode === 'capacitor';
const buildVersion = process.env.CARDY_BUILD_VERSION || (command === 'build' ? buildVersionFromDate(new Date()) : 'dev');
// Local development without the reverse proxy: /api/* -> server-be with /api stripped. // Local development without the reverse proxy: /api/* -> server-be with /api stripped.
const proxy: Record<string, ProxyOptions> = { const proxy: Record<string, ProxyOptions> = {
'/api': { '/api': {
target: env.BACKEND_URL || 'http://127.0.0.1:10002', target: env.BACKEND_URL || 'http://127.0.0.1:10002',
rewrite: (path) => path.replace(/^\/api/, '') || '/', rewrite: (p) => p.replace(/^\/api/, '') || '/',
headers: { 'X-Forwarded-Prefix': '/api' }, headers: { 'X-Forwarded-Prefix': '/api' },
}, },
}; };
@@ -47,9 +102,14 @@ export default defineConfig(({ mode }) => {
return { return {
base: capacitor ? './' : WEB_BASE, base: capacitor ? './' : WEB_BASE,
define: {
__BUILD_VERSION__: JSON.stringify(buildVersion),
__UPDATE_URL__: JSON.stringify((env.CARDY_UPDATE_URL || 'https://dev.aggtaa.com/m/android').replace(/\/+$/, '')),
},
plugins: [ plugins: [
react(), react(),
restoreStrippedBase(), cardyServer(),
...(capacitor ? [emitVersionFile(buildVersion)] : []),
VitePWA({ VitePWA({
disable: capacitor, disable: capacitor,
registerType: 'autoUpdate', registerType: 'autoUpdate',
@@ -75,7 +135,7 @@ export default defineConfig(({ mode }) => {
// bwip-js and the zxing wasm module are large but needed offline. // bwip-js and the zxing wasm module are large but needed offline.
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
navigateFallback: 'index.html', navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api(\/|$)/], navigateFallbackDenylist: [/^\/api(\/|$)/, /\/android\//],
}, },
}), }),
], ],
+20
View File
@@ -22,6 +22,9 @@ importers:
'@capacitor/preferences': '@capacitor/preferences':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.5.2) version: 8.0.1(@capacitor/core@8.5.2)
'@capgo/capacitor-updater':
specifier: ^8.51.25
version: 8.51.25(@capacitor/core@8.5.2)
'@cardy/shared': '@cardy/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@@ -56,6 +59,9 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) version: 6.1.1(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))
fflate:
specifier: ^0.8.3
version: 0.8.3
typescript: typescript:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2 version: 7.0.2
@@ -691,6 +697,11 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': '>=8.0.0' '@capacitor/core': '>=8.0.0'
'@capgo/capacitor-updater@8.51.25':
resolution: {integrity: sha512-lznyAv9Ktl1CD6/OKZY+lQZl9ke+dbadLEB2XQKP7VbQO8/5D8yOHS+pysQgVnNkiZYEC3v1AxgY4a5Z+DnxXw==}
peerDependencies:
'@capacitor/core': ^8.0.0
'@esbuild/aix-ppc64@0.28.2': '@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1818,6 +1829,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
filelist@1.0.6: filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
@@ -3731,6 +3745,10 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.5.2 '@capacitor/core': 8.5.2
'@capgo/capacitor-updater@8.51.25(@capacitor/core@8.5.2)':
dependencies:
'@capacitor/core': 8.5.2
'@esbuild/aix-ppc64@0.28.2': '@esbuild/aix-ppc64@0.28.2':
optional: true optional: true
@@ -4702,6 +4720,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
fflate@0.8.3: {}
filelist@1.0.6: filelist@1.0.6:
dependencies: dependencies:
minimatch: 5.1.9 minimatch: 5.1.9
+2
View File
@@ -18,6 +18,7 @@ const cardSchema = z.object({
code: z.coerce.string(), code: z.coerce.string(),
codeType: z.enum(CODE_TYPES), codeType: z.enum(CODE_TYPES),
comment: z.string().default(''), comment: z.string().default(''),
tags: z.array(z.string()).default([]),
logo: imageRefSchema.nullable().default(null), logo: imageRefSchema.nullable().default(null),
images: z.array(imageRefSchema).default([]), images: z.array(imageRefSchema).default([]),
imagesVersion: z.number().int().default(0), imagesVersion: z.number().int().default(0),
@@ -112,6 +113,7 @@ export class CardsService {
id, id,
...fields, ...fields,
comment: fields.comment ?? '', comment: fields.comment ?? '',
tags: fields.tags ?? [],
logo: logoRef, logo: logoRef,
images: [], images: [],
imagesVersion: logoRef ? 1 : 0, imagesVersion: logoRef ? 1 : 0,
+27
View File
@@ -152,6 +152,33 @@ describe('client API', () => {
expect((patched.json() as Card).comment).toBe('From admin\nsecond line'); expect((patched.json() as Card).comment).toBe('From admin\nsecond line');
}); });
it('parses comma-separated tags and keeps them on partial updates', async () => {
const { app, key } = await setup();
const api = client(app, key);
const created = (await api('POST', '/cards', [...cardParts(), { name: 'tags', value: 'один,,, два' }])).json() as Card;
expect(created.tags).toEqual(['один', 'два']);
const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card;
expect(renamed.tags).toEqual(['один', 'два']);
const retagged = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: ' food , ,pharmacy ' }])).json() as Card;
expect(retagged.tags).toEqual(['food', 'pharmacy']);
const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: '' }])).json() as Card;
expect(cleared.tags).toEqual([]);
expect((await api('POST', '/cards', cardParts())).json()).toMatchObject({ tags: [] });
const patched = await app.inject({
method: 'PATCH',
url: `/admin/cards/${created.id}`,
headers: adminHeaders,
payload: { tags: [' a ', '', 'b, c'] },
});
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
});
it('returns JSON 404 for unknown API routes', async () => { it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup(); const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope'); const res = await client(app, key)('GET', '/nope');
+30 -4
View File
@@ -1,5 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CODE_TYPES, CODE_TYPE_LABELS, cardFieldsSchema, randomCardColor, type Card, type CardFields } from '@cardy/shared'; import {
CODE_TYPES,
CODE_TYPE_LABELS,
cardFieldsSchema,
formatTags,
parseTags,
randomCardColor,
type Card,
type CardFields,
} from '@cardy/shared';
import { api } from '../api.js'; import { api } from '../api.js';
import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js'; import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js';
import { linkClick, navigate } from '../router.js'; import { linkClick, navigate } from '../router.js';
@@ -30,10 +39,12 @@ function Editor({ initial }: { initial?: Card }) {
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType, comment: initial.comment ?? '' } ? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType, comment: initial.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' }, : { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
); );
// Edited as typed ("a, b"); parsed into the tag list on save.
const [tagsText, setTagsText] = useState(formatTags(initial?.tags));
const action = useAction(); const action = useAction();
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => setSaved(false), [fields]); useEffect(() => setSaved(false), [fields, tagsText]);
const dirty = const dirty =
!card || !card ||
@@ -41,12 +52,13 @@ function Editor({ initial }: { initial?: Card }) {
card.color !== fields.color || card.color !== fields.color ||
card.code !== fields.code || card.code !== fields.code ||
card.codeType !== fields.codeType || card.codeType !== fields.codeType ||
(card.comment ?? '') !== (fields.comment ?? ''); (card.comment ?? '') !== (fields.comment ?? '') ||
(card.tags ?? []).join('\n') !== parseTags(tagsText).join('\n');
const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value })); const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value }));
const save = async (e: React.FormEvent) => { const save = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const parsed = cardFieldsSchema.safeParse(fields); const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
if (!parsed.success) { if (!parsed.success) {
action.setError(parsed.error.issues.map((i) => i.message).join('; ')); action.setError(parsed.error.issues.map((i) => i.message).join('; '));
return; return;
@@ -54,6 +66,7 @@ function Editor({ initial }: { initial?: Card }) {
const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data))); const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data)));
if (!result) return; if (!result) return;
setCard(result); setCard(result);
setTagsText(formatTags(result.tags));
setSaved(true); setSaved(true);
if (!card) navigate(`/cards/${result.id}`, { replace: true }); if (!card) navigate(`/cards/${result.id}`, { replace: true });
}; };
@@ -133,6 +146,19 @@ function Editor({ initial }: { initial?: Card }) {
onChange={(e) => set('comment', e.target.value)} onChange={(e) => set('comment', e.target.value)}
/> />
</div> </div>
<div className="field">
<label htmlFor="tags">Tags</label>
<input id="tags" value={tagsText} onChange={(e) => setTagsText(e.target.value)} placeholder="Comma-separated, e.g. food, pharmacy" />
{parseTags(tagsText).length > 0 && (
<div className="row">
{parseTags(tagsText).map((t) => (
<span key={t} className="pill tag-pill">
{t}
</span>
))}
</div>
)}
</div>
<div className="row"> <div className="row">
<button type="submit" disabled={action.busy || (!dirty && !!card)}> <button type="submit" disabled={action.busy || (!dirty && !!card)}>
{card ? 'Save' : 'Create card'} {card ? 'Save' : 'Create card'}
+17 -9
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CODE_TYPE_LABELS } from '@cardy/shared'; import { CODE_TYPE_LABELS, cardMatchesQuery } from '@cardy/shared';
import { api } from '../api.js'; import { api } from '../api.js';
import { CardBadge, ErrorText, useLoad } from '../components.js'; import { CardBadge, ErrorText, useLoad } from '../components.js';
import { linkClick } from '../router.js'; import { linkClick } from '../router.js';
@@ -9,13 +9,14 @@ export function CardsPage() {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [showDeleted, setShowDeleted] = useState(false); const [showDeleted, setShowDeleted] = useState(false);
const visible = useMemo(() => { // Search by name, tags and comment.
const q = filter.trim().toLowerCase(); const visible = useMemo(
return (cards.data ?? []) () =>
.filter((c) => showDeleted || !c.deleted) (cards.data ?? [])
.filter((c) => !q || c.name.toLowerCase().includes(q)) .filter((c) => (showDeleted || !c.deleted) && cardMatchesQuery(c, filter))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name)),
}, [cards.data, filter, showDeleted]); [cards.data, filter, showDeleted],
);
const deletedCount = cards.data?.filter((c) => c.deleted).length ?? 0; const deletedCount = cards.data?.filter((c) => c.deleted).length ?? 0;
return ( return (
@@ -27,7 +28,13 @@ export function CardsPage() {
</a> </a>
</div> </div>
<div className="row"> <div className="row">
<input className="grow" type="search" placeholder="Filter by name" value={filter} onChange={(e) => setFilter(e.target.value)} /> <input
className="grow"
type="search"
placeholder="Search by name, tag or comment"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<label className="checkbox"> <label className="checkbox">
<input type="checkbox" checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)} /> <input type="checkbox" checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)} />
Show deleted ({deletedCount}) Show deleted ({deletedCount})
@@ -45,6 +52,7 @@ export function CardsPage() {
{CODE_TYPE_LABELS[c.codeType]} {CODE_TYPE_LABELS[c.codeType]}
{c.images.length > 0 && ` · ${c.images.length} image${c.images.length === 1 ? '' : 's'}`} {c.images.length > 0 && ` · ${c.images.length} image${c.images.length === 1 ? '' : 's'}`}
</div> </div>
{c.tags?.length > 0 && <div className="card-tags small">{c.tags.join(' · ')}</div>}
</div> </div>
{c.deleted && <span className="pill danger">deleted</span>} {c.deleted && <span className="pill danger">deleted</span>}
</a> </a>
+12
View File
@@ -420,6 +420,18 @@ section > .row {
opacity: 0.55; opacity: 0.55;
} }
.pill.tag-pill {
margin-left: 0;
color: var(--text);
}
.card-tags {
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-name { .card-name {
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
+7
View File
@@ -1,3 +1,10 @@
/** Case-insensitive substring search over the card's name, tags and comment; an empty query matches all. */
export function cardMatchesQuery(card: { name: string; tags?: readonly string[]; comment?: string }, query: string): boolean {
const q = query.trim().toLocaleLowerCase();
if (!q) return true;
return [card.name, ...(card.tags ?? []), card.comment ?? ''].some((text) => text.toLocaleLowerCase().includes(q));
}
/** Colors that keep white text readable. */ /** Colors that keep white text readable. */
export const CARD_PALETTE = [ export const CARD_PALETTE = [
'#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C', '#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C',
+2
View File
@@ -22,6 +22,8 @@ export interface Card {
codeType: CodeType; codeType: CodeType;
/** Free text, may be multiline; '' when empty. */ /** Free text, may be multiline; '' when empty. */
comment: string; comment: string;
/** Trimmed, non-empty, unique; [] when none. */
tags: string[];
logo: ImageRef | null; logo: ImageRef | null;
images: ImageRef[]; images: ImageRef[];
imagesVersion: number; imagesVersion: number;
+33
View File
@@ -4,6 +4,37 @@ import { ROLES } from './models.js';
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB'); export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
export const MAX_TAGS = 30;
export const MAX_TAG_LENGTH = 50;
/**
* Tags from a comma-separated string (or a list of such strings): split by commas, trim each tag,
* drop empty ones and exact duplicates. "один,,, два" -> ["один", "два"].
*/
export function parseTags(input: string | readonly string[]): string[] {
const parts = (typeof input === 'string' ? [input] : input).flatMap((s) => s.split(','));
const tags: string[] = [];
for (const part of parts) {
const tag = part.trim();
if (tag && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
/** Tags written as the user types them: "a, b, c". */
export function formatTags(tags: readonly string[] | undefined): string {
return (tags ?? []).join(', ');
}
export const tagsSchema = z
.union([z.string(), z.array(z.string())])
.transform(parseTags)
.pipe(
z
.array(z.string().max(MAX_TAG_LENGTH, `A tag is too long (max ${MAX_TAG_LENGTH} characters)`))
.max(MAX_TAGS, `Too many tags (max ${MAX_TAGS})`),
);
export const cardFieldsSchema = z.object({ export const cardFieldsSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(200), name: z.string().trim().min(1, 'Name is required').max(200),
color: colorSchema, color: colorSchema,
@@ -16,6 +47,8 @@ export const cardFieldsSchema = z.object({
.transform((s) => s.replace(/\r\n?/g, '\n').trim()) .transform((s) => s.replace(/\r\n?/g, '\n').trim())
.pipe(z.string().max(2000, 'Comment is too long (max 2000 characters)')) .pipe(z.string().max(2000, 'Comment is too long (max 2000 characters)'))
.optional(), .optional(),
// Comma-separated string or a list; no default: partial updates must not clear them.
tags: tagsSchema.optional(),
}); });
export type CardFields = z.infer<typeof cardFieldsSchema>; export type CardFields = z.infer<typeof cardFieldsSchema>;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { cardMatchesQuery } from './cardDisplay.js';
import { cardFieldsSchema, formatTags, parseTags, tagsSchema } from './schemas.js';
describe('tags', () => {
it('splits by commas, trims and drops empty tags', () => {
expect(parseTags('один,,, два')).toEqual(['один', 'два']);
expect(parseTags(' food , pharmacy, ')).toEqual(['food', 'pharmacy']);
expect(parseTags('')).toEqual([]);
expect(parseTags(' , ,, ')).toEqual([]);
});
it('keeps inner spaces and case, drops exact duplicates', () => {
expect(parseTags('Home Goods, home goods, Home Goods')).toEqual(['Home Goods', 'home goods']);
});
it('accepts lists of comma-separated strings', () => {
expect(parseTags(['a, b', ' c ', ''])).toEqual(['a', 'b', 'c']);
});
it('validates limits and formats back', () => {
expect(tagsSchema.safeParse('x'.repeat(51)).success).toBe(false);
expect(tagsSchema.safeParse(Array.from({ length: 31 }, (_, i) => `t${i}`).join(',')).success).toBe(false);
expect(formatTags(['a', 'b c'])).toBe('a, b c');
});
it('is optional in card fields (partial updates keep tags)', () => {
const base = { name: 'Shop', color: '#123456', code: '1', codeType: 'QR' as const };
expect(cardFieldsSchema.parse(base).tags).toBeUndefined();
expect(cardFieldsSchema.parse({ ...base, tags: 'один,,, два' }).tags).toEqual(['один', 'два']);
expect(cardFieldsSchema.partial().parse({}).tags).toBeUndefined();
});
});
describe('cardMatchesQuery', () => {
const card = { name: 'Пятёрочка', tags: ['продукты', 'Food'], comment: 'Карта Анны\nсканировать дважды' };
it('searches name, tags and comment, case-insensitively', () => {
expect(cardMatchesQuery(card, 'пятёр')).toBe(true);
expect(cardMatchesQuery(card, 'ПРОДУКТ')).toBe(true);
expect(cardMatchesQuery(card, 'food')).toBe(true);
expect(cardMatchesQuery(card, 'анны')).toBe(true);
expect(cardMatchesQuery(card, 'аптека')).toBe(false);
expect(cardMatchesQuery(card, ' ')).toBe(true);
});
it('handles cards stored before tags and comments existed', () => {
expect(cardMatchesQuery({ name: 'Old' }, 'old')).toBe(true);
expect(cardMatchesQuery({ name: 'Old' }, 'x')).toBe(false);
});
});