diff --git a/apps/admin-panel/nginx.conf b/apps/admin-panel/nginx.conf index 9064b3a..c7c4592 100644 --- a/apps/admin-panel/nginx.conf +++ b/apps/admin-panel/nginx.conf @@ -45,6 +45,28 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # The files that say which release is live are never cached: index.html + # (it names the hashed bundles), sw.js (a stale service worker keeps serving + # the old precache) and version.json (the update gate polls it). They used + # to fall under the 6-month rule below, which could pin a browser to an old + # release. Exact matches win over that regex. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /version.json { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/admin-panel/src/App.vue b/apps/admin-panel/src/App.vue index b11406f..d0fa724 100644 --- a/apps/admin-panel/src/App.vue +++ b/apps/admin-panel/src/App.vue @@ -21,6 +21,7 @@ >
+
@@ -36,6 +37,7 @@ import { notification } from 'ant-design-vue'; import faIR from 'ant-design-vue/es/locale/fa_IR'; import themeConfig from './themeConfig'; import { useLayoutStore } from './stores/layout'; +import AppUpdateGate from './components/common/AppUpdateGate.vue'; import { userName } from './utils'; const layout = useLayoutStore(); diff --git a/apps/admin-panel/src/components/common/AppUpdateGate.vue b/apps/admin-panel/src/components/common/AppUpdateGate.vue new file mode 100644 index 0000000..de0fb14 --- /dev/null +++ b/apps/admin-panel/src/components/common/AppUpdateGate.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/admin-panel/src/composables/useAppUpdate.ts b/apps/admin-panel/src/composables/useAppUpdate.ts new file mode 100644 index 0000000..a3712f9 --- /dev/null +++ b/apps/admin-panel/src/composables/useAppUpdate.ts @@ -0,0 +1,84 @@ +// The update gate (scripts/app-version.mjs builds the other half). +// +// The bundle knows its own build number (__APP_BUILD__). Every few minutes, +// and whenever the app comes back to the foreground, it reads version.json +// from the server, bypassing every cache. A newer build there means a release +// went out: the modal lists what changed since the build this tab is running, +// and «به‌روزرسانی» drops the service worker and every Cache Storage entry, +// then reloads — so the next load is the new release, not a precached copy. +// localStorage is left alone: the sign-in and settings survive the update. +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' + +declare const __APP_BUILD__: number +declare const __APP_VERSION__: string + +export interface VersionInfo { + build: number + version: string + history: { build: number; date: string; notes: string[] }[] +} + +const CHECK_EVERY_MS = 5 * 60 * 1000 + +export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0 +export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '' + +export function useAppUpdate() { + const available = ref(null) + const updating = ref(false) + + /** What changed between the running build and the server's, newest first. */ + const changes = computed(() => { + const info = available.value + if (!info) return [] + return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build) + }) + + async function check() { + if (import.meta.env.DEV || !runningBuild || available.value) return + try { + const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' }) + if (!res.ok) return + const info = (await res.json()) as VersionInfo + if (typeof info.build === 'number' && info.build > runningBuild) available.value = info + } catch { + // Offline or a deploy in progress: try again on the next tick. + } + } + + async function applyUpdate() { + updating.value = true + try { + const regs = await navigator.serviceWorker?.getRegistrations?.() + if (regs) await Promise.all(regs.map((r) => r.unregister())) + } catch { + /* no service worker */ + } + try { + if ('caches' in window) { + const keys = await caches.keys() + await Promise.all(keys.map((k) => caches.delete(k))) + } + } catch { + /* Cache Storage unavailable */ + } + window.location.reload() + } + + let timer: ReturnType | undefined + const onVisible = () => { + if (document.visibilityState === 'visible') check() + } + + onMounted(() => { + check() + timer = setInterval(check, CHECK_EVERY_MS) + document.addEventListener('visibilitychange', onVisible) + }) + onBeforeUnmount(() => { + if (timer) clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + }) + + return { available, changes, updating, applyUpdate, check } +} diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts index 03e6f7e..5c5ca93 100644 --- a/apps/admin-panel/vite.config.ts +++ b/apps/admin-panel/vite.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath, URL } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; +import { appVersion } from '../../scripts/app-version.mjs'; // Ported from legacy henza_admin/vue.config.js. // The legacy build switched publicPath/outputDir on NODE_ENV=='branch'; in Vite the @@ -23,7 +24,8 @@ export default defineConfig(({ mode }) => { // the image only has `/assets/…` → white screen 404. // Branch mode still mounts under `/branch/` when that mode is used. base: isBranch ? '/branch/' : '/', - plugins: [vue()], + // Build number + «what changed» notes → version.json (scripts/app-version.mjs). + plugins: [vue(), appVersion('admin-panel')], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/apps/user-app-v2/nginx.conf b/apps/user-app-v2/nginx.conf index ac3d472..b8c6183 100644 --- a/apps/user-app-v2/nginx.conf +++ b/apps/user-app-v2/nginx.conf @@ -43,6 +43,28 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # The files that say which release is live are never cached: index.html + # (it names the hashed bundles), sw.js (a stale service worker keeps serving + # the old precache) and version.json (the update gate polls it). They used + # to fall under the 6-month rule below, which could pin a browser to an old + # release. Exact matches win over that regex. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /version.json { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/user-app-v2/src/App.vue b/apps/user-app-v2/src/App.vue index 952463c..643cb8a 100644 --- a/apps/user-app-v2/src/App.vue +++ b/apps/user-app-v2/src/App.vue @@ -1,6 +1,6 @@ + + + + + + + + diff --git a/apps/user-app-v2/src/composables/useAppUpdate.ts b/apps/user-app-v2/src/composables/useAppUpdate.ts new file mode 100644 index 0000000..7198575 --- /dev/null +++ b/apps/user-app-v2/src/composables/useAppUpdate.ts @@ -0,0 +1,84 @@ +// The update gate (scripts/app-version.mjs builds the other half). +// +// The bundle knows its own build number (__APP_BUILD__). Every few minutes, +// and whenever the app comes back to the foreground, it reads version.json +// from the server, bypassing every cache. A newer build there means a release +// went out: the modal lists what changed since the build this tab is running, +// and «به‌روزرسانی» drops the service worker and every Cache Storage entry, +// then reloads — so the next load is the new release, not a precached copy. +// localStorage is left alone: sign-in, cart and settings survive the update. +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' + +declare const __APP_BUILD__: number +declare const __APP_VERSION__: string + +export interface VersionInfo { + build: number + version: string + history: { build: number; date: string; notes: string[] }[] +} + +const CHECK_EVERY_MS = 5 * 60 * 1000 + +export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0 +export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '' + +export function useAppUpdate() { + const available = ref(null) + const updating = ref(false) + + /** What changed between the running build and the server's, newest first. */ + const changes = computed(() => { + const info = available.value + if (!info) return [] + return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build) + }) + + async function check() { + if (import.meta.env.DEV || !runningBuild || available.value) return + try { + const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' }) + if (!res.ok) return + const info = (await res.json()) as VersionInfo + if (typeof info.build === 'number' && info.build > runningBuild) available.value = info + } catch { + // Offline or a deploy in progress: try again on the next tick. + } + } + + async function applyUpdate() { + updating.value = true + try { + const regs = await navigator.serviceWorker?.getRegistrations?.() + if (regs) await Promise.all(regs.map((r) => r.unregister())) + } catch { + /* no service worker */ + } + try { + if ('caches' in window) { + const keys = await caches.keys() + await Promise.all(keys.map((k) => caches.delete(k))) + } + } catch { + /* Cache Storage unavailable */ + } + window.location.reload() + } + + let timer: ReturnType | undefined + const onVisible = () => { + if (document.visibilityState === 'visible') check() + } + + onMounted(() => { + check() + timer = setInterval(check, CHECK_EVERY_MS) + document.addEventListener('visibilitychange', onVisible) + }) + onBeforeUnmount(() => { + if (timer) clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + }) + + return { available, changes, updating, applyUpdate, check } +} diff --git a/apps/user-app-v2/src/composables/useRefresh.ts b/apps/user-app-v2/src/composables/useRefresh.ts deleted file mode 100644 index 6f62268..0000000 --- a/apps/user-app-v2/src/composables/useRefresh.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Ported from legacy henza_pwa/src/Mixins/useRefresh.ts. -// Auto-applies a waiting service worker as soon as one is available, so the -// PWA self-updates instead of serving a stale precache indefinitely. -import { unref, watchEffect } from 'vue' -import { useRegisterSW } from 'virtual:pwa-register/vue' - -export function useRefresh() { - const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW() - - const refreshApp = async () => { - await updateServiceWorker() - offlineReady.value = false - needRefresh.value = false - } - - watchEffect(async () => (unref(offlineReady) || unref(needRefresh)) && (await refreshApp())) -} diff --git a/apps/user-app-v2/vite.config.ts b/apps/user-app-v2/vite.config.ts index d44e87a..81eac3b 100644 --- a/apps/user-app-v2/vite.config.ts +++ b/apps/user-app-v2/vite.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath, URL } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; +import { appVersion } from '../../scripts/app-version.mjs'; import { VitePWA } from 'vite-plugin-pwa'; import { $themeLessVars } from './themeConfig/themeConfig'; import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance'; @@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000); export default defineConfig({ envDir: rootDir, plugins: [ + // Build number + «what changed» notes → version.json (scripts/app-version.mjs). + appVersion('user-app-v2'), vue(), // virtual:palette.css — the light/dark values behind the theme tokens. palettePlugin(), diff --git a/apps/user-app/nginx.conf b/apps/user-app/nginx.conf index f61f04a..6b6a171 100644 --- a/apps/user-app/nginx.conf +++ b/apps/user-app/nginx.conf @@ -46,6 +46,28 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # The files that say which release is live are never cached: index.html + # (it names the hashed bundles), sw.js (a stale service worker keeps serving + # the old precache) and version.json (the update gate polls it). They used + # to fall under the 6-month rule below, which could pin a browser to an old + # release. Exact matches win over that regex. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /version.json { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/user-app/src/App.vue b/apps/user-app/src/App.vue index ad0ee9e..643cb8a 100644 --- a/apps/user-app/src/App.vue +++ b/apps/user-app/src/App.vue @@ -1,6 +1,6 @@ - - - - - - - + @@ -45,22 +27,19 @@ diff --git a/apps/user-app/src/composables/useAppUpdate.ts b/apps/user-app/src/composables/useAppUpdate.ts new file mode 100644 index 0000000..7198575 --- /dev/null +++ b/apps/user-app/src/composables/useAppUpdate.ts @@ -0,0 +1,84 @@ +// The update gate (scripts/app-version.mjs builds the other half). +// +// The bundle knows its own build number (__APP_BUILD__). Every few minutes, +// and whenever the app comes back to the foreground, it reads version.json +// from the server, bypassing every cache. A newer build there means a release +// went out: the modal lists what changed since the build this tab is running, +// and «به‌روزرسانی» drops the service worker and every Cache Storage entry, +// then reloads — so the next load is the new release, not a precached copy. +// localStorage is left alone: sign-in, cart and settings survive the update. +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' + +declare const __APP_BUILD__: number +declare const __APP_VERSION__: string + +export interface VersionInfo { + build: number + version: string + history: { build: number; date: string; notes: string[] }[] +} + +const CHECK_EVERY_MS = 5 * 60 * 1000 + +export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0 +export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '' + +export function useAppUpdate() { + const available = ref(null) + const updating = ref(false) + + /** What changed between the running build and the server's, newest first. */ + const changes = computed(() => { + const info = available.value + if (!info) return [] + return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build) + }) + + async function check() { + if (import.meta.env.DEV || !runningBuild || available.value) return + try { + const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' }) + if (!res.ok) return + const info = (await res.json()) as VersionInfo + if (typeof info.build === 'number' && info.build > runningBuild) available.value = info + } catch { + // Offline or a deploy in progress: try again on the next tick. + } + } + + async function applyUpdate() { + updating.value = true + try { + const regs = await navigator.serviceWorker?.getRegistrations?.() + if (regs) await Promise.all(regs.map((r) => r.unregister())) + } catch { + /* no service worker */ + } + try { + if ('caches' in window) { + const keys = await caches.keys() + await Promise.all(keys.map((k) => caches.delete(k))) + } + } catch { + /* Cache Storage unavailable */ + } + window.location.reload() + } + + let timer: ReturnType | undefined + const onVisible = () => { + if (document.visibilityState === 'visible') check() + } + + onMounted(() => { + check() + timer = setInterval(check, CHECK_EVERY_MS) + document.addEventListener('visibilitychange', onVisible) + }) + onBeforeUnmount(() => { + if (timer) clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + }) + + return { available, changes, updating, applyUpdate, check } +} diff --git a/apps/user-app/src/composables/useRefresh.ts b/apps/user-app/src/composables/useRefresh.ts deleted file mode 100644 index 6f62268..0000000 --- a/apps/user-app/src/composables/useRefresh.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Ported from legacy henza_pwa/src/Mixins/useRefresh.ts. -// Auto-applies a waiting service worker as soon as one is available, so the -// PWA self-updates instead of serving a stale precache indefinitely. -import { unref, watchEffect } from 'vue' -import { useRegisterSW } from 'virtual:pwa-register/vue' - -export function useRefresh() { - const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW() - - const refreshApp = async () => { - await updateServiceWorker() - offlineReady.value = false - needRefresh.value = false - } - - watchEffect(async () => (unref(offlineReady) || unref(needRefresh)) && (await refreshApp())) -} diff --git a/apps/user-app/vite.config.ts b/apps/user-app/vite.config.ts index 52bc8d0..ece2db1 100644 --- a/apps/user-app/vite.config.ts +++ b/apps/user-app/vite.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath, URL } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; +import { appVersion } from '../../scripts/app-version.mjs'; import { VitePWA } from 'vite-plugin-pwa'; import { $themeLessVars } from './themeConfig/themeConfig'; import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance'; @@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000); export default defineConfig({ envDir: rootDir, plugins: [ + // Build number + «what changed» notes → version.json (scripts/app-version.mjs). + appVersion('user-app'), vue(), // virtual:palette.css — the light/dark values behind the theme tokens. palettePlugin(), diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md new file mode 100644 index 0000000..3d62102 --- /dev/null +++ b/docs/RELEASE-NOTES.md @@ -0,0 +1,52 @@ +# نسخه و پیام «چه چیزی تغییر کرد» + +هر بار که پنل یا اپ‌ها ساخته می‌شوند، شماره‌ی نسخه **خودش بالا می‌رود**؛ شماره همان +تعداد کامیت‌های شاخه است و فایلی برای دستی بالا بردن ندارد. + +بعد از انتشار، هر کسی که برنامه را باز دارد حداکثر تا ۵ دقیقه، یا همان لحظه‌ای که +دوباره به برنامه برمی‌گردد، پنجره‌ی «نسخه‌ی تازه آماده است» را می‌بیند: + +- فهرست تغییراتِ **همان بخش** نشان داده می‌شود: اپ مشتری تغییرات اپ را می‌بیند و + پنل تغییرات پنل را. +- پنجره بسته نمی‌شود؛ تنها راه، دکمه‌ی «به‌روزرسانی» است. +- با زدن دکمه، حافظه‌ی نهان برنامه پاک و صفحه دوباره بار می‌شود. +- ورود کاربر، سبد خرید و تنظیمات **پاک نمی‌شوند**. + +## متن تغییرات از کجا می‌آید؟ + +از پیام کامیت‌ها. متن فنی و انگلیسی **هیچ‌وقت** به کاربر نشان داده نمی‌شود؛ فقط +خط‌هایی که با یکی از این برچسب‌ها شروع شوند: + +| برچسب | کجا نشان داده می‌شود | +|---|---| +| `اپ:` | هر دو اپ مشتری | +| `اپ۱:` | فقط اپ نسخه‌ی ۱ | +| `اپ۲:` | فقط اپ نسخه‌ی ۲ | +| `پنل:` | پنل مدیریت | + +نمونه‌ی پیام کامیت: + +``` +fix(cart): keep the cart when the price refreshes + +اپ: سبد خرید با به‌روز شدن قیمت دیگر خالی نمی‌شود. +پنل: در فهرست سفارش‌ها، تاریخ ثبت دقیق نشان داده می‌شود. +``` + +### چطور بنویسیم + +- برای **کاربر** بنویسید، نه برنامه‌نویس: «چه چیزی برای او فرق کرده». +- یک خط کوتاه برای هر تغییر. +- کامیتی که کاربر چیزی از آن نمی‌بیند (تمیزکاری، تست، تغییر داخلی) **برچسب نمی‌خواهد**. +- اگر در یک انتشار هیچ خطِ برچسب‌داری نباشد، پنجره فقط «بهبودها و رفع اشکال‌ها» + را نشان می‌دهد. + +## از نظر فنی + +- `scripts/app-version.mjs` موقع ساخت، شماره و یادداشت‌ها را درمی‌آورد و + `version.json` را کنار برنامه می‌گذارد. +- `useAppUpdate.ts` در هر برنامه آن فایل را بدون حافظه‌ی نهان می‌خواند و با شماره‌ی + خودش مقایسه می‌کند. +- تنظیم nginx هر سه برنامه: `index.html`، `sw.js` و `version.json` هرگز در مرورگر + نگه داشته نمی‌شوند. پیش‌تر `sw.js` شش ماه نگه داشته می‌شد و می‌توانست مرورگر را + روی نسخه‌ی قدیمی نگه دارد. diff --git a/nginx-spa.conf b/nginx-spa.conf index 5b46960..dc5aee1 100644 --- a/nginx-spa.conf +++ b/nginx-spa.conf @@ -14,6 +14,28 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # The files that say which release is live are never cached: index.html + # (it names the hashed bundles), sw.js (a stale service worker keeps serving + # the old precache) and version.json (the update gate polls it). They used + # to fall under the 6-month rule below, which could pin a browser to an old + # release. Exact matches win over that regex. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /version.json { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + try_files $uri =404; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/scripts/app-version.d.mts b/scripts/app-version.d.mts new file mode 100644 index 0000000..212b7ed --- /dev/null +++ b/scripts/app-version.d.mts @@ -0,0 +1,14 @@ +import type { Plugin } from 'vite'; + +export interface AppVersionInfo { + app: string; + build: number; + version: string; + commit: string | null; + builtAt: string; + history: { build: number; date: string; notes: string[] }[]; +} + +export function notesFor(app: string, message: string): string[]; +export function computeAppVersion(app: string, cwd?: string): AppVersionInfo; +export function appVersion(app: 'user-app' | 'user-app-v2' | 'admin-panel'): Plugin; diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs new file mode 100644 index 0000000..cc45438 --- /dev/null +++ b/scripts/app-version.mjs @@ -0,0 +1,89 @@ +// App version + «what changed» notes, baked into each front-end at build time. +// +// Version: the build number is the commit count of HEAD, so it only ever goes +// up and needs no file to bump. It is compiled into the bundle (__APP_BUILD__) +// and written to dist/version.json; the running app polls that file and, when +// the server's build is newer than its own, shows the update modal. +// +// Notes come from commit messages. Technical text is never shown: only lines +// that start with a Persian tag, written for the person using that part: +// +// اپ: … both customer apps +// اپ۱: … user-app only +// اپ۲: … user-app-v2 only +// پنل: … the admin panel +// +// A commit may carry several such lines. Each app shows only its own. + +import { execFileSync } from 'node:child_process'; + +const TAGS = { + 'user-app': ['اپ', 'اپ۱', 'اپ1'], + 'user-app-v2': ['اپ', 'اپ۲', 'اپ2'], + 'admin-panel': ['پنل'], +}; + +/** How many released builds with notes the file keeps. */ +const HISTORY = 30; + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +} + +/** Pure, for testing: the note lines in one commit message that belong to `app`. */ +export function notesFor(app, message) { + const tags = TAGS[app] ?? []; + const out = []; + for (const raw of message.split(/\r?\n/)) { + const m = /^\s*([^\s::]+)\s*[::]\s*(.+?)\s*$/.exec(raw); + // The note must be Persian: a wrapped English line that happens to begin + // with a tag (e.g. in a commit body explaining the tags) is not a note. + if (m && tags.includes(m[1]) && /[؀-ۿ]/.test(m[2])) out.push(m[2]); + } + return out; +} + +export function computeAppVersion(app, cwd = process.cwd()) { + const builtAt = new Date().toISOString(); + try { + const build = Number(git(['rev-list', '--count', 'HEAD'], cwd).trim()); + const commit = git(['rev-parse', '--short', 'HEAD'], cwd).trim(); + // Newest first; a record separator between commits, unit separator between fields. + const log = git(['log', '-n', '400', '--format=%ct%x1f%B%x1e'], cwd); + const history = []; + log.split('\x1e').forEach((rec, i) => { + const [ts, body] = rec.replace(/^\s+/, '').split('\x1f'); + if (!body) return; + const notes = notesFor(app, body); + if (!notes.length) return; + history.push({ build: build - i, date: new Date(Number(ts) * 1000).toISOString(), notes }); + }); + return { app, build, version: `1.${build}`, commit, builtAt, history: history.slice(0, HISTORY) }; + } catch { + // Not a git checkout (e.g. a copied tree): still a valid, if note-less, build. + const build = Math.floor(Date.now() / 1000); + return { app, build, version: `1.${build}`, commit: null, builtAt, history: [] }; + } +} + +/** + * Vite plugin: defines __APP_BUILD__ / __APP_VERSION__ and emits version.json. + * The dev server has no version.json, so the update check stays quiet there. + */ +export function appVersion(app) { + const info = computeAppVersion(app); + return { + name: 'app-version', + config() { + return { + define: { + __APP_BUILD__: JSON.stringify(info.build), + __APP_VERSION__: JSON.stringify(info.version), + }, + }; + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify(info, null, 2) }); + }, + }; +}