From 534dcce6da4ea2c0038595bce42b1f424b7d3c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:08:14 +0300 Subject: [PATCH] =?UTF-8?q?Yeni=20versiyon=20g=C3=BCncellemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +- .../Migrations/LanguagesData.json | 6 + configs/deployment/scripts/build/ui.sh | 11 +- ui/package.json | 2 +- ui/public/version.json | 167 ++- ui/scripts/generate-version.js | 126 +- ui/scripts/generate-version.sh | 57 +- ui/src/components/template/Theme.tsx | 10 +- ui/src/components/template/UserDropdown.tsx | 3 + ui/src/sw.ts | 716 ++++++++++ ui/src/utils/hooks/useAuth.ts | 9 +- ui/src/views/version/AppUpdateDialog.tsx | 262 ++++ ui/src/views/version/ChangeLog.tsx | 16 +- ui/src/views/version/InstallAppMenuItem.tsx | 37 + ui/src/views/version/installPrompt.ts | 87 ++ ui/src/views/version/mountAppUpdateDialog.tsx | 21 + ui/src/views/version/swRegistration.ts | 1264 +++++++++++------ ui/src/views/version/useAppVersionNotice.tsx | 77 - ui/vite.config.ts | 177 +-- 19 files changed, 2288 insertions(+), 777 deletions(-) create mode 100644 ui/src/sw.ts create mode 100644 ui/src/views/version/AppUpdateDialog.tsx create mode 100644 ui/src/views/version/InstallAppMenuItem.tsx create mode 100644 ui/src/views/version/installPrompt.ts create mode 100644 ui/src/views/version/mountAppUpdateDialog.tsx delete mode 100644 ui/src/views/version/useAppVersionNotice.tsx diff --git a/README.md b/README.md index 6b573b7a..02004c30 100644 --- a/README.md +++ b/README.md @@ -684,14 +684,17 @@ konfigürasyonu ile üretilir. ### 18.4. PWA ve sürüm yönetimi -- Precache **yalnızca uygulama kabuğunu** kapsar (index.html + entry JS/CSS ≈ 2.8 MB); DevExtreme - temaları, lazy chunk'lar, font ve görseller runtime cache ile saklanır. Sebebi: - `dist` klasörü ~55 MB olduğundan tam precache, yavaş bağlantılarda service worker kurulumunun - timeout'a düşmesine yol açıyordu. -- `registerType: 'prompt'` — yeni sürüm indirilir, kullanıcı onayıyla devreye alınır - (`views/version/swRegistration.ts`, `useAppVersionNotice.tsx`). +- Kurulum iki kademelidir: **kabuk** (index.html + entry JS/CSS + eager vendor chunk'ları) + workbox precache manifest'inden gelir ve zorunludur; **bileşenler** (tüm lazy chunk'lar, + build'de üretilen `dist/warmup.json`) güncelleme penceresinde aynı ilerleme çubuğunda arka + planda indirilir. İkinci kademe best-effort'tur (inemeyen dosya kurulumu düşürmez, süre + bütçesi aşılırsa kalanlar runtime cache'e kalır); böylece deploy sonrası ilk gezinmede chunk + beklenmez ama yavaş bağlantıda kurulum timeout'a düşmez. DevExtreme temaları (~33 MB), font + ve görseller runtime cache'tedir. +- `registerType: 'prompt'` — yeni sürüm indirilir, indirme bitince otomatik devreye alınır + (`views/version/swRegistration.ts`, `sw.ts`, `AppUpdateDialog.tsx`). - `version.json` her zaman ağdan tazelenir (NetworkFirst); changelog `/admin/changeLog` - ekranında gösterilir. + ekranında gösterilir, yeni deploy sonrası ilk girişte oraya yönlendirilir. - `scripts/generate-version.js` build sırasında sürüm bilgisini üretir. --- diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index d0545d02..94e85182 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -114,6 +114,12 @@ } ], "LanguageTexts": [ + { + "resourceName": "Platform", + "key": "App.InstallApp", + "en": "Install app", + "tr": "Uygulamayı yükle" + }, { "resourceName": "Platform", "key": "App.Product.ListComponent", diff --git a/configs/deployment/scripts/build/ui.sh b/configs/deployment/scripts/build/ui.sh index a569b9c7..ca5ad684 100644 --- a/configs/deployment/scripts/build/ui.sh +++ b/configs/deployment/scripts/build/ui.sh @@ -4,12 +4,17 @@ echo "Env değeri: ${ENV}" cd ~/sozsoft-platform git checkout main -git fetch && git pull +# --prune-tags: sunucuda elle atılmış, origin'de olmayan yerel tag'leri siler. +# Bunlar temizlenmezse `git tag` listesinde kalır ve deploy yanlış (daha yüksek) +# bir sürüm numarasıyla etiketlenir. +git fetch --prune --prune-tags --tags && git pull cd ~/sozsoft-platform/ui -# Git tag'den son versiyonu al (örn: v1.0.11 -> 1.0.11), yoksa package.json'daki versiyon -VERSION=$(git tag --sort=-creatordate | head -n1 | sed 's/^v//' || jq -r .version package.json) +# Sürüm, HEAD'den erişilebilen son tag'dir (başka daldaki tag'ler sayılmaz); +# tag yoksa package.json'daki sürüm kullanılır. +VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') +VERSION=${VERSION:-$(jq -r .version package.json)} echo "Build versiyonu: ${VERSION}" # Deploy öncesi git üzerindeki tüm versiyon bilgilerini güncelle diff --git a/ui/package.json b/ui/package.json index e3e0c622..4300e350 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "vite", "build": "node scripts/generate-version.js && vite build", - "build:production": "vite build", + "build:production": "node scripts/generate-version.js && vite build", "preview": "vite preview", "lint": "eslint .", "lint:fix": "npm run lint -- --fix", diff --git a/ui/public/version.json b/ui/public/version.json index 13df1d2c..b3af35d1 100644 --- a/ui/public/version.json +++ b/ui/public/version.json @@ -1,20 +1,17 @@ { - "commit": "95a7b22", + "buildId": "98e9f84.msq55max", + "buildTime": "2026-08-12T13:44:50.404Z", + "buildDate": "2026-08-12", + "commit": "98e9f84", + "packageVersion": "1.0.7", + "version": "1.2.1", "releases": [ { - "version": "1.1.12", - "buildDate": "2026-08-12", - "commit": "5cfd0105bc7100471edd21f8c1d215fdd6ab4d40", - "changeLog": [ - "- Yeni versiyon güncellemesi" - ] - }, - { - "version": "1.1.11", + "version": "1.2.1", "buildDate": "2026-08-11", "commit": "783c1fe1d35f7cb8f23fb63fad26123cbe54aafc", "changeLog": [ - "- Uygulamanın genel optimizasyonu" + "Claude.ai güncellemesi" ] }, { @@ -22,10 +19,10 @@ "buildDate": "2026-07-31", "commit": "2eef3ead35848341350659828a14dc4a463e57af", "changeLog": [ - "- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.", - "- Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.", - "- Scheduler ve TodoBard komponentler geliştirildi.", - "- Lookup editorlerin Static değerlere dil desteği eklendi." + "Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.", + "Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.", + "Scheduler ve TodoBard komponentler geliştirildi.", + "Lookup editorlerin Static değerlere dil desteği eklendi." ] }, { @@ -33,21 +30,21 @@ "buildDate": "2026-07-29", "commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e", "changeLog": [ - "- Wizard yetenekleri artırıldı. Menu Order eklendi.", - "- GanttView sample düzenlemeleri", - "- Schedulerview sample düzenlemeleri", - "- CryptographyXml version 10.0.10" + "Wizard yetenekleri artırıldı. Menu Order eklendi.", + "GanttView sample düzenlemeleri", + "Schedulerview sample düzenlemeleri", + "CryptographyXml version 10.0.10" ] }, { "version": "1.1.08", "buildDate": "2026-07-16", - "commit": "e5afb66d89cefc48beff4bbaa00c140bdc58fadc", + "commit": "24e92fcb82eef0ee26ca5d65e08c5404f132a09e", "changeLog": [ - "- CardView komponenti geliştirildi.", - "- Todo / Kanban komponenti geliştirildi.", - "- Licence information", - "- Check New Versiyon komponenti." + "CardView komponenti geliştirildi.", + "Todo / Kanban komponenti geliştirildi.", + "Licence information", + "Check New Versiyon komponenti." ] }, { @@ -55,11 +52,11 @@ "buildDate": "2026-07-13", "commit": "50cb3eaf979eb5027172a92a4e941ed64aa3671e", "changeLog": [ - "- localStorage tüm bilgiler easy-peasy içerisinde birleştirildi.", - "- Codex 5.6 sol optimizasyon yapıldı. Bir çok komponent Lazy load ile yüklendi.", - "- Workflow komponentinden birden fazla kişi onay verebilir.", - "- Posta Kodu eklendi.", - "- Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı" + "localStorage tüm bilgiler easy-peasy içerisinde birleştirildi.", + "Codex 5.6 sol optimizasyon yapıldı. Bir çok komponent Lazy load ile yüklendi.", + "Workflow komponentinden birden fazla kişi onay verebilir.", + "Posta Kodu eklendi.", + "Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı" ] }, { @@ -67,9 +64,9 @@ "buildDate": "2026-07-06", "commit": "bab639ce6343b451b81d6688e4f7af666f0233da", "changeLog": [ - "- Çekirdek kod için optimizasyon", - "- List Reportlara Gruplu ve Alttoplam eklendi.", - "- Paketlerdeki deprecated uyarları kaldırıldı" + "Çekirdek kod için optimizasyon", + "List Reportlara Gruplu ve Alttoplam eklendi.", + "Paketlerdeki deprecated uyarları kaldırıldı" ] }, { @@ -77,12 +74,12 @@ "buildDate": "2026-06-27", "commit": "352cf047b0af1b8aad53b632a41161efcc6cecdf", "changeLog": [ - "- Ai için Ollama kurulumu yapıldı. Ayrıca Gemma LLM ile çalıştırıldı.", - "- DynamicReport eklendi. ReportDesigner ile çalışan bir rapor tasarlandı.", - "- DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.", - "- DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.", - "- DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.", - "- Demo sayfası menüden ayrıldı." + "Ai için Ollama kurulumu yapıldı. Ayrıca Gemma LLM ile çalıştırıldı.", + "DynamicReport eklendi. ReportDesigner ile çalışan bir rapor tasarlandı.", + "DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.", + "DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.", + "DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.", + "Demo sayfası menüden ayrıldı." ] }, { @@ -90,22 +87,22 @@ "buildDate": "2026-06-04", "commit": "20e7fae481ce69e9a678508ce03b5ed7831aea9f", "changeLog": [ - "- Settingde yapılan ayarlar Auth komponentlerine uygulandı.", - "- Public home ve diğer sayfaların tasarım değişikliği yapıldı.", - "- Route Type Dinamik ve Normal olarak ayrıldı.", - "- Form Devexpress DefaultValue özelliği eklendi.", - "- Devexpress DarkModa uygun şekilde güncellendi.", - "- Grid, Tree ve FormDevexpress setReadonly özelliği eklendi." + "Settingde yapılan ayarlar Auth komponentlerine uygulandı.", + "Public home ve diğer sayfaların tasarım değişikliği yapıldı.", + "Route Type Dinamik ve Normal olarak ayrıldı.", + "Form Devexpress DefaultValue özelliği eklendi.", + "Devexpress DarkModa uygun şekilde güncellendi.", + "Grid, Tree ve FormDevexpress setReadonly özelliği eklendi." ] }, { "version": "1.1.03", "buildDate": "2026-05-30", - "commit": "f9a65a3c8b2920ea40abcba501f0e8673f51f09b", + "commit": "96f7091d46c248ba3c42849fe5d870db0ab96982", "changeLog": [ - "- User Detail komponentinin içerisinde Avatar ekleme", - "- EditForm un içerisinde EditorOptions dinamik oluşturulması", - "- Editform un içerisinde EditorScript dinamik oluşturulması" + "User Detail komponentinin içerisinde Avatar ekleme", + "EditForm un içerisinde EditorOptions dinamik oluşturulması", + "Editform un içerisinde EditorScript dinamik oluşturulması" ] }, { @@ -113,11 +110,11 @@ "buildDate": "2026-05-27", "commit": "84b9f6510787bd82f3797728fd675f755b4caa2d", "changeLog": [ - "- .NET 10 yükseltildi.", - "- Abp Framework 10 yükseltildi.", - "- Sql Query Manager problemleri giderildi.", - "- AuditLog problemleri giderildi.", - "- Tenan yapısını uygunluğu için düzenlemeler yapıldı." + ".NET 10 yükseltildi.", + "Abp Framework 10 yükseltildi.", + "Sql Query Manager problemleri giderildi.", + "AuditLog problemleri giderildi.", + "Tenan yapısını uygunluğu için düzenlemeler yapıldı." ] }, { @@ -125,10 +122,10 @@ "buildDate": "2026-05-24", "commit": "6262baa6f12d695a25d83304af985092715d439a", "changeLog": [ - "- Workflow tanımlaması yapılabilir.", - "- Dark mod için uygulama güncellemesi", - "- Form Field kısmında düzenleme", - "- Hangfire Recurring Job düzenlemesi." + "Workflow tanımlaması yapılabilir.", + "Dark mod için uygulama güncellemesi", + "Form Field kısmında düzenleme", + "Hangfire Recurring Job düzenlemesi." ] }, { @@ -136,19 +133,19 @@ "buildDate": "2026-05-11", "commit": "414006204e324018be597a598d3dd102868c5cca", "changeLog": [ - "- Backup dosyalarını son 5 günün kalması", - "- Grid için Fit Columns özelliği eklendi.", - "- Notification Desktop, UiActivity, UiToast özelliği eklendi", - "- Versiyon güncellemeleri için \"System Updating\" mesajı" + "Backup dosyalarını son 5 günün kalması", + "Grid için Fit Columns özelliği eklendi.", + "Notification Desktop, UiActivity, UiToast özelliği eklendi", + "Versiyon güncellemeleri için \"System Updating\" mesajı" ] }, { "version": "1.0.9", "buildDate": "2026-05-09", - "commit": "13e35b961f4a0cb7288ee04e179fa6f61ee996ab", + "commit": "e9d8f5ebac35fc7b21f08139dfe175ed548c14b4", "changeLog": [ - "- Video Rooms düzenlemesi", - "- Genel Liste düzenlemeleri" + "Video Rooms düzenlemesi", + "Genel Liste düzenlemeleri" ] }, { @@ -156,10 +153,10 @@ "buildDate": "2026-05-07", "commit": "6fa266f23ed4f67384b7bef0e4030420cb788af5", "changeLog": [ - "- Intranet ve Sosyal Duvar", - "- Listelerde ImageUploadandViewer", - "- Survey Widget", - "- Event, Category ve Type" + "Intranet ve Sosyal Duvar", + "Listelerde ImageUploadandViewer", + "Survey Widget", + "Event, Category ve Type" ] }, { @@ -167,10 +164,10 @@ "buildDate": "2026-05-04", "commit": "4444fce93b4f62ad6698099fefe7da1614d2859c", "changeLog": [ - "- Departman tanımlama", - "- Job Position tanımlama", - "- Wizard ve SQl Data güncellemeleri", - "- Organizasyon Şemanı eklendi" + "Departman tanımlama", + "Job Position tanımlama", + "Wizard ve SQl Data güncellemeleri", + "Organizasyon Şemanı eklendi" ] }, { @@ -178,10 +175,10 @@ "buildDate": "2026-05-03", "commit": "b2dfb04879a93709b04f8f0fc3bd4f5af1f7c01b", "changeLog": [ - "- SalesOrder ve SalesOrderItem listFormu tanımlandı.", - "- Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.", - "- Wizard File Manager komponenti oluşturuldu.", - "- Wizard komponentini Edit özelliği eklendi." + "SalesOrder ve SalesOrderItem listFormu tanımlandı.", + "Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.", + "Wizard File Manager komponenti oluşturuldu.", + "Wizard komponentini Edit özelliği eklendi." ] }, { @@ -189,11 +186,11 @@ "buildDate": "2026-04-28", "commit": "b9cc68ff41d675fefadab79f5ea2c5e7f376708a", "changeLog": [ - "- AllowAdding özelliği eklendi.", - "- Kulllanıcı bazında Workhour özelliği eklendi.", - "- ConcurrentUser sınırlaması getirildi.", - "- Uygulamadan kullanıcıyı kickleme özelliği getirildi.", - "- Uygulama açıkken DbMigrator çağrısı yapıldı." + "AllowAdding özelliği eklendi.", + "Kulllanıcı bazında Workhour özelliği eklendi.", + "ConcurrentUser sınırlaması getirildi.", + "Uygulamadan kullanıcıyı kickleme özelliği getirildi.", + "Uygulama açıkken DbMigrator çağrısı yapıldı." ] }, { @@ -201,9 +198,9 @@ "buildDate": "2026-03-30", "commit": "e9ce256c0706de408e08a462bee9e4a72653f2c7", "changeLog": [ - "- Role Yetkilerini kopyalama", - "- Place Holder güncellemesi", - "- Kayıt kopyalama özelliği" + "Role Yetkilerini kopyalama", + "Place Holder güncellemesi", + "Kayıt kopyalama özelliği" ] }, { @@ -234,4 +231,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/ui/scripts/generate-version.js b/ui/scripts/generate-version.js index 9926d684..b616d9c8 100644 --- a/ui/scripts/generate-version.js +++ b/ui/scripts/generate-version.js @@ -1,67 +1,117 @@ -// scripts/write-version.js +// scripts/generate-version.js +// +// public/version.json üretir. Bu dosya uygulamanın tek sürüm kaynağıdır: +// - `buildId` : HER deploy'da değişir (commit + build zamanı). Uygulama açık +// sekmede ve açılışta yeni deploy'u bununla anlar; git tag +// atılmamış olsa bile güncelleme algılanır. +// - `version` : Kullanıcıya gösterilen sürüm (en güncel release). +// - `releases` : Changelog kaynağı. Git tag'lerinden okunur; HEAD tag'li +// değilse son tag'den bu yana atılan commit'ler "yayınlanmamış" +// sürüm olarak en üste eklenir, böylece her deploy'un notu olur. import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' import { execSync } from 'child_process' function safeExec(cmd) { try { - return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true }) - .toString() - .trim() + return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true }).toString().trim() } catch { return null } } -let releases = [] -let commit = null +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const uiRoot = path.resolve(scriptDir, '..') +const outputFile = path.join(uiRoot, 'public', 'version.json') -if (fs.existsSync('.git') || fs.existsSync('../.git')) { - // Git varsa: tag’lerden oku - const rawTags = safeExec('git tag --list --sort=creatordate') - if (rawTags) { - const tags = rawTags.split('\n').filter(Boolean) - releases = tags.map((tag) => { +const pkg = JSON.parse(fs.readFileSync(path.join(uiRoot, 'package.json'), 'utf8')) +const packageVersion = pkg.version || '0.0.0' + +const buildTime = new Date().toISOString() +const buildDate = buildTime.slice(0, 10) + +const hasGit = !!safeExec('git rev-parse --is-inside-work-tree') + +/** + * Docker imajı içinde `.git` yoktur; sürüm bilgisi host'ta (deploy script'i ile) + * üretilip `public/version.json` olarak kopyalanır. Bu durumda dosyayı yeniden + * üretmek gerçek changelog'u ve `buildId`'yi silip yerine "yerel derleme" + * yazardı; bu yüzden mevcut dosya korunur. + */ +if (!hasGit && fs.existsSync(outputFile)) { + const existing = JSON.parse(fs.readFileSync(outputFile, 'utf8')) + const version = existing.version ?? existing.releases?.[0]?.version ?? packageVersion + const buildId = existing.buildId ?? existing.commit ?? `${packageVersion}.${Date.now().toString(36)}` + + if (!existing.buildId || !existing.version) { + existing.buildId = buildId + existing.version = version + fs.writeFileSync(outputFile, JSON.stringify(existing, null, 2)) + } + console.log(`> mevcut version.json korundu: v${version} (${buildId})`) + process.exit(0) +} +const shortCommit = process.env.GIT_COMMIT || (hasGit ? safeExec('git rev-parse --short HEAD') : null) +const commit = shortCommit || 'local' + +/** Tag mesajını changelog satırlarına çevirir. */ +const toChangeLog = (raw) => + (raw ?? '') + .split('\n') + .map((line) => line.replace(/^\s*[-*]\s*/, '').trim()) + .filter(Boolean) + +let releases = [] + +if (hasGit) { + // Sıralama tag adına göre (creatordate değil): sunucuda sonradan atılmış bir + // tag listeyi başa geçirip yanlış sürüm göstermesin. + const rawTags = safeExec('git tag --list --sort=-v:refname') + const tags = rawTags ? rawTags.split('\n').filter(Boolean) : [] + + releases = tags + .map((tag) => { const version = tag.replace(/^v/, '') const date = safeExec(`git log -1 --format=%ad --date=short ${tag}`) const commitId = safeExec(`git rev-list -n 1 ${tag}`) - const messageRaw = safeExec(`git tag -l --format="%(contents)" ${tag}`) - - const changeLog = messageRaw - ? messageRaw - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - : ['No changelog for this tag'] + const changeLog = toChangeLog(safeExec(`git tag -l --format="%(contents)" ${tag}`)) return { version, - buildDate: date, + buildDate: date || buildDate, commit: commitId, - changeLog, + changeLog: changeLog.length ? changeLog : ['Bu sürüm için not girilmemiş'], } }) - } - commit = safeExec('git rev-parse --short HEAD') -} else { - // Git yoksa (ör. dev build) - const pkg = JSON.parse(fs.readFileSync('package.json')) - const version = pkg.version || '0.0.0' - commit = process.env.GIT_COMMIT || 'dev-local' + // Changelog yalnızca tag'lerden oluşur. Tag'siz commit'ler için sahte bir + // sürüm üretilmez; sürüm notlarını tag mesajı belirler. Deploy algılaması + // sürüm numarasına değil, her derlemede değişen `buildId`'ye bakar. +} + +if (releases.length === 0) { releases = [ { - version, - buildDate: new Date().toISOString().slice(0, 10), + version: packageVersion, + buildDate, commit, - changeLog: [ - 'Local development build', - 'Git bilgisi mevcut değil, package.json versiyonu kullanıldı', - ], + changeLog: ['Yerel geliştirme derlemesi', 'Git bilgisi bulunamadı, package.json sürümü kullanıldı'], }, ] } -const versionInfo = { commit, releases: releases.reverse() } +const versionInfo = { + // Her derlemede değişir; uygulamanın deploy algılaması buna bakar. + buildId: `${commit}.${Date.now().toString(36)}`, + buildTime, + buildDate, + commit, + packageVersion, + version: releases[0].version, + releases, +} -fs.writeFileSync('public/version.json', JSON.stringify(versionInfo, null, 2)) -//console.log('> version.json güncellendi:', versionInfo) +fs.mkdirSync(path.dirname(outputFile), { recursive: true }) +fs.writeFileSync(outputFile, JSON.stringify(versionInfo, null, 2)) +console.log(`> version.json: v${versionInfo.version} (${versionInfo.buildId})`) diff --git a/ui/scripts/generate-version.sh b/ui/scripts/generate-version.sh index 2bc1b23e..d7d42ac2 100644 --- a/ui/scripts/generate-version.sh +++ b/ui/scripts/generate-version.sh @@ -1,35 +1,50 @@ #!/bin/bash set -e +# public/version.json üretir (deploy öncesi, HOST üzerinde). +# +# Asıl uygulama scripts/generate-version.js dosyasıdır; node varsa o çalışır. +# Aşağıdaki bash karşılığı yalnızca node bulunmayan sunucular içindir ve +# js sürümüyle aynı çıktıyı üretmelidir. + +cd "$(dirname "$0")/.." + echo "> version.json oluşturuluyor..." -# En yeni tag en üstte olsun -VERSIONS=$(git tag --sort=-creatordate) +if command -v node >/dev/null 2>&1; then + node scripts/generate-version.js + exit 0 +fi + +# origin'de olmayan yerel tag'ler sürüm numarasını bozar; temizle. +git fetch --prune --prune-tags --tags origin >/dev/null 2>&1 || true + +# Sürüm sırası tag adına göre (creatordate değil); 1.1.10 > 1.1.09. +VERSIONS=$(git tag --sort=-v:refname) ROOT_COMMIT=$(git rev-parse --short HEAD) +BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) +BUILD_DATE=$(date -u +%Y-%m-%d) +# Her deploy'da değişir; uygulamanın yeni sürüm algılaması buna bakar. +BUILD_ID="${ROOT_COMMIT}.$(date -u +%s)" -OUTPUT="{\"commit\":\"$ROOT_COMMIT\",\"releases\":[" - +RELEASES="" FIRST=true for TAG in $VERSIONS; do - VER=$(echo $TAG | sed 's/^v//') - DATE=$(git log -1 --format=%ad --date=short $TAG) - COMMIT=$(git rev-list -n 1 $TAG) + VER=$(echo "$TAG" | sed 's/^v//') + DATE=$(git log -1 --format=%ad --date=short "$TAG") + COMMIT=$(git rev-list -n 1 "$TAG") + MESSAGE=$(git tag -l --format="%(contents)" "$TAG" | grep -v '^$' | sed 's/^[[:space:]]*-[[:space:]]*//' | jq -R . | jq -s .) - # 🔑 Boş satırları filtrele - MESSAGE=$(git tag -l --format="%(contents)" $TAG | grep -v '^$' | jq -R . | jq -s .) - - if [ "$FIRST" = true ]; then - FIRST=false - else - OUTPUT+="," - fi - - OUTPUT+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}" + if [ "$FIRST" = true ]; then FIRST=false; else RELEASES+=","; fi + RELEASES+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}" done -OUTPUT+="]}" +# Sürüm = en son tag. Tag'siz commit'ler için sahte sürüm üretilmez; deploy +# algılaması sürüm numarasına değil, her derlemede değişen buildId'ye bakar. +LAST_TAG=$(echo "$VERSIONS" | head -n1 | sed 's/^v//') +VERSION=${LAST_TAG:-$(jq -r .version package.json)} -echo "$OUTPUT" | jq . > public/version.json +printf '{"buildId":"%s","buildTime":"%s","buildDate":"%s","commit":"%s","version":"%s","releases":[%s]}' \ + "$BUILD_ID" "$BUILD_TIME" "$BUILD_DATE" "$ROOT_COMMIT" "$VERSION" "$RELEASES" | jq . > public/version.json -echo "> public/version.json güncellendi:" -#cat public/version.json +echo "> version.json: v${VERSION} (${BUILD_ID})" diff --git a/ui/src/components/template/Theme.tsx b/ui/src/components/template/Theme.tsx index a471631f..abace5ea 100644 --- a/ui/src/components/template/Theme.tsx +++ b/ui/src/components/template/Theme.tsx @@ -10,7 +10,7 @@ import { Helmet } from 'react-helmet' import { useNavigate, useLocation } from 'react-router-dom' import { getSetupStatus } from '@/services/setup.service' import { ROUTES_ENUM } from '@/routes/route.constant' -import { useAppVersionNotice } from '@/views/version/useAppVersionNotice' +import { getRunningVersion } from '@/views/version/swRegistration' let didInit = false @@ -73,7 +73,13 @@ const Theme = (props: CommonProps) => { 'en' useDarkMode() useTabFocus() - useAppVersionNotice() + + // Çalışan bundle'ın sürümü footer'da gösterilir; derleme anında gömülüdür, + // ağ isteği gerektirmez. + const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion) + useEffect(() => { + setUiVersion(getRunningVersion()) + }, [setUiVersion]) type ConfigProviderValue = NonNullable['value']> diff --git a/ui/src/components/template/UserDropdown.tsx b/ui/src/components/template/UserDropdown.tsx index 18c6aa48..6fb4a21f 100644 --- a/ui/src/components/template/UserDropdown.tsx +++ b/ui/src/components/template/UserDropdown.tsx @@ -17,6 +17,7 @@ import { FcVoicePresentation, } from 'react-icons/fc' import { useManualUpdateCheck } from '@/views/version/ChangeLog' +import InstallAppMenuItem from '@/views/version/InstallAppMenuItem' type DropdownList = { label: string @@ -122,6 +123,8 @@ const UserDropdownBase = ({ className }: CommonProps) => { ) })} + + ) => void + respondWith: (response: Response | Promise) => void + request: Request + data?: { type?: string } | null + source?: { postMessage: (message: unknown) => void } | null +} + +type SwClient = { id: string; postMessage: (message: unknown) => void } + +declare const self: { + __WB_MANIFEST: PrecacheEntry[] + location: Location + navigator?: { connection?: { saveData?: boolean; effectiveType?: string } } + registration: { scope: string } + skipWaiting: () => Promise + clients: { + claim: () => Promise + matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise + } + addEventListener: (type: string, listener: (event: SwEvent) => void) => void +} + +/* ------------------------------------------------------------------ */ +/* Sabitler */ +/* ------------------------------------------------------------------ */ + +const SHELL_CACHE = 'sozsoft-shell-v1' +const ASSET_CACHE = 'sozsoft-assets-v1' +const THEME_CACHE = 'sozsoft-themes-v1' +const FONT_CACHE = 'sozsoft-fonts-v1' +const IMAGE_CACHE = 'sozsoft-images-v1' +const VERSION_CACHE = 'sozsoft-version-v1' + +const KNOWN_CACHES = [ + SHELL_CACHE, + ASSET_CACHE, + THEME_CACHE, + FONT_CACHE, + IMAGE_CACHE, + VERSION_CACHE, +] + +/** + * Bir sürümün tüm chunk'ları (~500 dosya) ısıtma ile bu cache'e yazıldığı için + * limit dosya sayısının belirgin üstünde olmalı; aksi halde ısıtılan bileşenler + * daha kurulum biterken birbirini siler. + */ +const ASSET_LIMIT = 1500 +const THEME_LIMIT = 12 +const FONT_LIMIT = 40 +const IMAGE_LIMIT = 150 + +/** Bileşen ısıtması için üst süre sınırı; aşılırsa kalanlar runtime cache'e kalır. */ +const WARMUP_BUDGET_MS = 180_000 +const SHELL_CONCURRENCY = 4 +const WARMUP_CONCURRENCY = 6 + +const HASHED_ASSET = /^\/assets\/.+\.(?:js|css|wasm)$/ +const THEME_CSS = /^\/css\/[\w.-]+\.css$/ +const FONT_FILE = /\.(?:woff2?|ttf|otf|eot)$/ +const IMAGE_FILE = /\.(?:png|jpe?g|svg|gif|webp|ico)$/ +const NETWORK_ONLY = /^\/(?:api|connect|swagger|signalr|hangfire)\b/ + +const MANIFEST: PrecacheEntry[] = self.__WB_MANIFEST ?? [] + +const INDEX_URL = new URL('index.html', self.registration.scope).href +const WARMUP_URL = new URL('warmup.json', self.registration.scope).href + +/** Hash'siz dosyalar için revision'ı cache anahtarına ekler. */ +const cacheKeyFor = (entry: PrecacheEntry) => { + const url = new URL(entry.url, self.registration.scope) + if (entry.revision) url.searchParams.set('__rev', entry.revision) + return url.href +} + +const shellKeys = new Map() +MANIFEST.forEach((entry) => { + shellKeys.set(new URL(entry.url, self.registration.scope).href, cacheKeyFor(entry)) +}) + +/* ------------------------------------------------------------------ */ +/* Isıtma listesi (build'de üretilen warmup.json) */ +/* ------------------------------------------------------------------ */ + +type WarmupEntry = { url: string; size: number } + +/** + * Boyutlar derleme anında yazıldığı için ilerleme yüzdesi ilk saniyeden + * itibaren doğrudur. Dosya yoksa (dev sunucusu ya da eski deploy) ısıtma + * sessizce atlanır; kabuk kurulumu bundan etkilenmez. + */ +async function fetchWarmupList(): Promise { + try { + const response = await fetch( + new Request(WARMUP_URL, { cache: 'reload', credentials: 'same-origin' }), + ) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + const data = (await response.json()) as { files?: WarmupEntry[] } | undefined + return (data?.files ?? []).map((entry) => ({ + url: new URL(entry.url, self.registration.scope).href, + size: Number(entry.size) || 0, + })) + } catch (error) { + console.warn('[sw] warmup.json okunamadı, bileşen ısıtması atlanıyor.', error) + return [] + } +} + +let warmupPromise: Promise | undefined +const loadWarmupList = () => (warmupPromise ??= fetchWarmupList()) + +/** Veri tasarrufu açıkken ya da 2G'de ~20 MB'lık ısıtma yapılmaz. */ +function isWarmupAllowed() { + const connection = self.navigator?.connection + if (!connection) return true + if (connection.saveData) return false + return !/2g$/.test(connection.effectiveType ?? '') +} + +/* ------------------------------------------------------------------ */ +/* İstemciye ilerleme yayını */ +/* ------------------------------------------------------------------ */ + +type ProgressFile = { + index: number + url: string + name: string + loaded: number + total: number + status: string +} + +async function broadcast(message: Record) { + // Yeni worker henüz sayfayı kontrol etmiyor; includeUncontrolled şart. + const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }) + clients.forEach((client) => client.postMessage(message)) +} + +const fileName = (url: string) => { + const pathname = new URL(url).pathname + return pathname.slice(pathname.lastIndexOf('/') + 1) || pathname +} + +/* ------------------------------------------------------------------ */ +/* Kurulum: ilerleme bildiren precache */ +/* ------------------------------------------------------------------ */ + +async function downloadWithProgress( + url: string, + key: string, + cache: Cache, + file: ProgressFile, + onTick: () => void, +) { + const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' })) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + + // Content-Length sıkıştırılmış boyuttur, okunan gövde ise açılmış hâldir; + // yalnızca derleme boyutu bilinmiyorsa (kabuk dosyaları) kaba tahmin olarak + // kullanılır. + const declared = Number(response.headers.get('content-length') || 0) + if (declared > 0 && file.total === 0) file.total = declared + + // Gövde akış olarak okunamıyorsa (ör. opak/derleyici farkı) progress'siz yaz. + if (!response.body) { + const buffer = await response.clone().arrayBuffer() + file.loaded = buffer.byteLength + file.total = file.total || buffer.byteLength + await cache.put(key, response) + return file.loaded + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let loaded = 0 + + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + chunks.push(value) + loaded += value.byteLength + file.loaded = loaded + if (file.total < loaded) file.total = loaded + onTick() + } + + // Parçaları tek tampona birleştir (Blob kopyasından kaçınmak için). + const body = new Uint8Array(loaded) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + + // Gövde ağ katmanında çözüldüğü için sıkıştırma başlıkları taşınmamalı; + // aksi halde cache'ten servis edilen dosya bozuk görünür. `vary` de silinir: + // girdiyi URL anahtarıyla yazıp Request ile okuduğumuz için Vary eşleşmesi + // kaydı görünmez yapabilir. + const headers = new Headers(response.headers) + headers.delete('content-encoding') + headers.delete('content-length') + headers.delete('vary') + await cache.put(key, new Response(body, { status: 200, headers })) + file.loaded = loaded + file.total = file.total || loaded + return loaded +} + +type DownloadJob = { + index: number + url: string + /** Cache anahtarı (kabukta revision'lı olabilir). */ + key: string + cache: Cache + /** Başarısızlığı kurulumu düşürür mü? */ + critical: boolean +} + +/** Cache'te zaten bulunan anahtarlar; 500 ayrı `match` çağrısından çok hızlı. */ +async function cachedKeys(cache: Cache) { + const requests = await cache.keys() + return new Set(requests.map((request) => request.url)) +} + +/** + * Kurulumda indirilecek işleri hazırlar: önce zorunlu kabuk, ardından + * (izin veriliyorsa) uygulamanın tüm bileşenleri. Hash'li ad = aynı içerik + * olduğu için cache'te bulunan hiçbir dosya yeniden indirilmez. + */ +async function planInstall() { + const shellCache = await caches.open(SHELL_CACHE) + const assetCache = await caches.open(ASSET_CACHE) + const warmup = await loadWarmupList() + const sizes = new Map(warmup.map((entry) => [entry.url, entry.size])) + + const jobs: DownloadJob[] = [] + const files: ProgressFile[] = [] + let reusedCount = 0 + + const push = (url: string, key: string, cache: Cache, critical: boolean) => { + const index = files.length + files.push({ + index, + url, + name: fileName(url), + loaded: 0, + total: sizes.get(url) ?? 0, + status: 'pending', + }) + jobs.push({ index, url, key, cache, critical }) + } + + const cachedShell = await cachedKeys(shellCache) + for (const entry of MANIFEST) { + const key = cacheKeyFor(entry) + if (cachedShell.has(key)) { + reusedCount++ + continue + } + push(new URL(entry.url, self.registration.scope).href, key, shellCache, true) + } + + const shellCount = jobs.length + + if (isWarmupAllowed()) { + const cachedAssets = await cachedKeys(assetCache) + for (const entry of warmup) { + // Kabuğa giren dosyalar zaten yukarıda ele alındı. + if (shellKeys.has(entry.url)) continue + if (cachedAssets.has(entry.url)) { + reusedCount++ + continue + } + push(entry.url, entry.url, assetCache, false) + } + } + + return { jobs, files, reusedCount, shellCount } +} + +/** İndirme sürerken anlık ilerleme; boştayken `undefined`. */ +let snapshot: (() => Record) | undefined + +type SessionOptions = { + files: ProgressFile[] + jobs: DownloadJob[] + /** `jobs` içindeki ilk kaç iş zorunlu (kabuk) — kalanı best-effort ısıtma. */ + criticalCount: number + reusedCount: number + /** Güncelleme kurulumu mu, yoksa eksik bileşenlerin tamamlanması mı? */ + phase: 'install' | 'warmup' +} + +/** + * Ortak indirme oturumu: ilerleme yayınlar, zorunlu işlerde hata fırlatır, + * ısıtma işlerinde hatayı yutar. Hem `install` olayı hem de sonradan çalışan + * `ensureWarmup` bunu kullanır. + */ +async function runSession({ files, jobs, criticalCount, reusedCount, phase }: SessionOptions) { + const totalBytes = files.reduce((sum, file) => sum + file.total, 0) + await broadcast({ type: 'SW_PRECACHE_START', files, reusedCount, totalBytes, phase }) + + if (jobs.length === 0) { + await broadcast({ type: 'SW_PRECACHE_DONE', files, loadedBytes: 0, totalBytes: 0, phase }) + return + } + + // İlerleme mesajları yalnızca değişen dosyaları taşır; 500 dosyalık listeyi + // saniyede birkaç kez kopyalamak hem SW'yi hem render'ı gereksiz yorar. + const dirty = new Set() + const inFlight = new Set() + let lastTick = 0 + let completedBytes = 0 + + // Biten dosyalar + hâlâ inen dosyaların o anki baytı. İnen dosya sayısı + // eşzamanlılık kadar (≤6) olduğu için toplam her tick'te ucuza hesaplanır. + const loadedNow = () => { + let total = completedBytes + for (const index of inFlight) total += files[index].loaded + return total + } + + // Tarayıcı, worker güncellemesini sayfa gezinmesinde otomatik başlatır; bu + // yüzden kurulum çoğu zaman uygulama mount olup mesaj dinleyicisini eklemeden + // önce başlar ve START mesajı kaçar. Sonradan bağlanan istemci anlık durumu + // buradan sorar (bkz. GET_PRECACHE_STATE). + snapshot = () => ({ files, reusedCount, totalBytes, phase, loadedBytes: loadedNow() }) + + const flush = (force = false) => { + const now = Date.now() + if (dirty.size === 0) return + if (!force && now - lastTick < 150) return + lastTick = now + const updates = Array.from(dirty, (index) => ({ ...files[index] })) + dirty.clear() + void broadcast({ + type: 'SW_PRECACHE_PROGRESS', + updates, + loadedBytes: loadedNow(), + totalBytes, + phase, + }) + } + + const run = async (pool: DownloadJob[], concurrency: number, deadline?: number) => { + let cursor = 0 + const worker = async () => { + for (;;) { + const next = cursor++ + if (next >= pool.length) return + // Süre bütçesi dolduysa kalan bileşenler runtime cache'e bırakılır. + if (deadline && Date.now() > deadline) return + + const job = pool[next] + const file = files[job.index] + file.status = 'downloading' + inFlight.add(job.index) + dirty.add(job.index) + flush() + + try { + await downloadWithProgress(job.url, job.key, job.cache, file, () => { + dirty.add(job.index) + flush() + }) + file.status = 'done' + } catch (error) { + file.status = 'error' + console.warn('[sw] Dosya indirilemedi:', job.url, error) + // Kabuk dosyası indirilemezse kurulum başarısız olmalı; yarım bir + // sürümle aktive olmak beyaz ekrana yol açar. Bileşen ısıtmasında ise + // dosya runtime cache'e bırakılır, kurulum devam eder. + if (job.critical) { + inFlight.delete(job.index) + throw error + } + } + inFlight.delete(job.index) + completedBytes += file.loaded + dirty.add(job.index) + flush(true) + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, pool.length) }, worker)) + } + + try { + await run(jobs.slice(0, criticalCount), SHELL_CONCURRENCY) + } catch (error) { + await broadcast({ + type: 'SW_PRECACHE_ERROR', + files, + phase, + message: error instanceof Error ? error.message : String(error), + }) + throw error + } + + const warmupJobs = jobs.slice(criticalCount) + if (warmupJobs.length > 0) { + await run(warmupJobs, WARMUP_CONCURRENCY, Date.now() + WARMUP_BUDGET_MS) + } + + flush(true) + await broadcast({ + type: 'SW_PRECACHE_DONE', + files, + loadedBytes: completedBytes, + totalBytes, + phase, + }) +} + +async function installAssets() { + const { jobs, files, reusedCount, shellCount } = await planInstall() + await runSession({ files, jobs, criticalCount: shellCount, reusedCount, phase: 'install' }) +} + +/** + * Eksik kalan bileşenleri tamamlar. + * + * Isıtmayı yalnızca `install` olayına bağlamak yetmiyor: süre bütçesi dolduğunda, + * sekme kapandığında ya da bir dosya hata verdiğinde kalan chunk'lar bir sonraki + * deploy'a kadar hiç indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu. + * Bu yüzden uygulama açılışta bunu tetikler (ENSURE_WARMUP) ve indirme kaldığı + * yerden sürer. + */ +let warmupRunning = false + +async function ensureWarmup() { + if (warmupRunning || !isWarmupAllowed()) return + warmupRunning = true + try { + const assetCache = await caches.open(ASSET_CACHE) + const warmup = await loadWarmupList() + const cached = await cachedKeys(assetCache) + + const files: ProgressFile[] = [] + const jobs: DownloadJob[] = [] + let reusedCount = 0 + + for (const entry of warmup) { + if (shellKeys.has(entry.url) || cached.has(entry.url)) { + reusedCount++ + continue + } + const index = files.length + files.push({ + index, + url: entry.url, + name: fileName(entry.url), + loaded: 0, + total: entry.size, + status: 'pending', + }) + jobs.push({ index, url: entry.url, key: entry.url, cache: assetCache, critical: false }) + } + + // Her şey yerinde: istemciyi rahatsız etme. + if (jobs.length === 0) return + + await runSession({ files, jobs, criticalCount: 0, reusedCount, phase: 'warmup' }) + } finally { + warmupRunning = false + snapshot = undefined + } +} + +self.addEventListener('install', (event) => { + event.waitUntil(installAssets().finally(() => (snapshot = undefined))) +}) + +/* ------------------------------------------------------------------ */ +/* Aktivasyon: eski girdileri temizle */ +/* ------------------------------------------------------------------ */ + +async function cleanup() { + const names = await caches.keys() + await Promise.all( + names + .filter((name) => name.startsWith('sozsoft-') && !KNOWN_CACHES.includes(name)) + .concat(names.filter((name) => /^(workbox-|app-|dx-|fonts-|images-)/.test(name))) + .map((name) => caches.delete(name)), + ) + + // Kabuk cache'inde bu sürüme ait olmayan girdileri sil. + const cache = await caches.open(SHELL_CACHE) + const valid = new Set(shellKeys.values()) + const requests = await cache.keys() + await Promise.all(requests.filter((r) => !valid.has(r.url)).map((r) => cache.delete(r))) + + await pruneAssetCache() +} + +/** + * Isıtma her sürümde ~500 chunk yazdığı için eski sürümlerin chunk'ları + * temizlenmezse cache sürekli büyür. Aktivasyon anında yapılır: eski sayfa + * zaten yenilenmek üzeredir, çalışan sürümün chunk'ı elinden alınmaz. + */ +async function pruneAssetCache() { + const warmup = await loadWarmupList() + // Liste okunamadıysa (dev / eski deploy) hiçbir şeyi silme. + if (warmup.length === 0) return + + const valid = new Set(warmup.map((entry) => entry.url)) + const cache = await caches.open(ASSET_CACHE) + const requests = await cache.keys() + + await Promise.all( + requests + .filter((request) => { + const url = new URL(request.url) + if (!/^\/assets\/(?:js|css)\//.test(url.pathname)) return false + const href = url.href.split('?')[0] + return !valid.has(href) && !shellKeys.has(href) + }) + .map((request) => cache.delete(request)), + ) +} + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + await cleanup() + await self.clients.claim() + await broadcast({ type: 'SW_ACTIVATED' }) + })(), + ) +}) + +/* ------------------------------------------------------------------ */ +/* Mesajlar */ +/* ------------------------------------------------------------------ */ + +self.addEventListener('message', (event) => { + const type = event.data?.type + if (type === 'SKIP_WAITING') { + void self.skipWaiting() + return + } + // Kurulum sürerken bağlanan istemci ilerlemeyi buradan alır; START mesajını + // kaçırmış olsa bile pencereyi açıp listeyi doldurabilir. + if (type === 'GET_PRECACHE_STATE') { + const current = snapshot?.() + if (current) event.source?.postMessage({ type: 'SW_PRECACHE_STATE', ...current }) + return + } + // Uygulama açılışta eksik bileşenlerin tamamlanmasını ister. + if (type === 'ENSURE_WARMUP') { + event.waitUntil(ensureWarmup()) + return + } + if (type === 'PING') { + event.source?.postMessage({ type: 'PONG', shellSize: MANIFEST.length }) + } +}) + +/* ------------------------------------------------------------------ */ +/* Runtime cache */ +/* ------------------------------------------------------------------ */ + +async function trimCache(cacheName: string, limit: number) { + const cache = await caches.open(cacheName) + const keys = await cache.keys() + if (keys.length <= limit) return + // Cache.keys() ekleme sırasını korur; en eskiler silinir. + await Promise.all(keys.slice(0, keys.length - limit).map((key) => cache.delete(key))) +} + +async function cacheFirst(request: Request, cacheName: string, limit: number) { + const cache = await caches.open(cacheName) + const cached = await cache.match(request) + if (cached) return cached + + const response = await fetch(request) + if (response.ok || response.type === 'opaque') { + await cache.put(request, response.clone()) + void trimCache(cacheName, limit) + } + return response +} + +async function staleWhileRevalidate(request: Request, cacheName: string, limit: number) { + const cache = await caches.open(cacheName) + const cached = await cache.match(request) + const network = fetch(request) + .then(async (response) => { + if (response.ok) { + await cache.put(request, response.clone()) + void trimCache(cacheName, limit) + } + return response + }) + .catch(() => undefined) + + return cached ?? (await network) ?? Response.error() +} + +async function networkFirst(request: Request, cacheName: string, timeoutMs: number) { + const cache = await caches.open(cacheName) + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const response = await fetch(request, { signal: controller.signal }) + clearTimeout(timer) + if (response.ok) await cache.put(request, response.clone()) + return response + } catch { + const cached = await cache.match(request) + return cached ?? Response.error() + } +} + +/** Kabuktaki index.html'i arka planda tazeler (deploy sonrası kendini onarır). */ +async function refreshIndex(cache: Cache, key: string, url: string) { + try { + const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' })) + if (response.ok) await cache.put(key, response) + } catch { + /* çevrimdışıysa mevcut kopya kullanılmaya devam eder */ + } +} + +/** + * SPA gezinmeleri: precache'teki index.html anında servis edilir (hızlı açılış), + * kopya arka planda tazelenir. Service worker'ın güncellenmediği (yalnızca lazy + * chunk değişen) deploy'larda bir sonraki açılış yeni index'i alır. + */ +async function handleNavigation(request: Request) { + const cache = await caches.open(SHELL_CACHE) + const key = shellKeys.get(INDEX_URL) + const cached = key ? await cache.match(key) : undefined + if (cached && key) { + void refreshIndex(cache, key, INDEX_URL) + return cached + } + try { + return await fetch(request) + } catch { + return Response.error() + } +} + +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + if (NETWORK_ONLY.test(url.pathname)) return + + if (request.mode === 'navigate') { + event.respondWith(handleNavigation(request)) + return + } + + // Sürüm bilgisi asla eski kalmamalı. + if (url.pathname.endsWith('/version.json')) { + event.respondWith(networkFirst(request, VERSION_CACHE, 3000)) + return + } + + const shellKey = shellKeys.get(url.href.split('?')[0]) + if (shellKey) { + event.respondWith( + caches + .open(SHELL_CACHE) + .then((cache) => cache.match(shellKey)) + .then((cached) => cached ?? fetch(request)), + ) + return + } + + if (HASHED_ASSET.test(url.pathname)) { + event.respondWith(cacheFirst(request, ASSET_CACHE, ASSET_LIMIT)) + return + } + if (THEME_CSS.test(url.pathname)) { + event.respondWith(staleWhileRevalidate(request, THEME_CACHE, THEME_LIMIT)) + return + } + if (FONT_FILE.test(url.pathname)) { + event.respondWith(cacheFirst(request, FONT_CACHE, FONT_LIMIT)) + return + } + if (IMAGE_FILE.test(url.pathname)) { + event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT)) + } +}) diff --git a/ui/src/utils/hooks/useAuth.ts b/ui/src/utils/hooks/useAuth.ts index 26c320d0..0cf960b7 100644 --- a/ui/src/utils/hooks/useAuth.ts +++ b/ui/src/utils/hooks/useAuth.ts @@ -13,6 +13,8 @@ import { isLoginSuccess } from '../../proxy/account/models' import { REDIRECT_URL_KEY } from '../../constants/app.constant' import appConfig from '../../proxy/configs/app.config' import { register } from '../../services/account.service' +import { hasPendingChangelog } from '@/views/version/swRegistration' +import { ROUTES_ENUM } from '@/routes/route.constant' type Status = 'success' | 'failed' | 'error' @@ -68,7 +70,12 @@ function useAuth() { handleToken({ token, refreshToken, expiresIn }) const redirectUrl = query.get(REDIRECT_URL_KEY) - navigate(redirectUrl ?? appConfig.authenticatedEntryPath) + // Uygulama yeni bir sürümle/deploy ile açıldıysa kullanıcı önce + // değişiklikleri görsün. Bayrak ChangeLog ekranında tüketilir. + const target = hasPendingChangelog() + ? ROUTES_ENUM.protected.admin.changeLog + : (redirectUrl ?? appConfig.authenticatedEntryPath) + navigate(target) return { status: 'success', diff --git a/ui/src/views/version/AppUpdateDialog.tsx b/ui/src/views/version/AppUpdateDialog.tsx new file mode 100644 index 00000000..18cb1a88 --- /dev/null +++ b/ui/src/views/version/AppUpdateDialog.tsx @@ -0,0 +1,262 @@ +import { memo, useSyncExternalStore } from 'react' +import { + applyAppUpdate, + dismissAppUpdateDialog, + getAppUpdateState, + subscribeAppUpdate, + type AppUpdateState, +} from './swRegistration' + +/** + * Sürüm indirme penceresi. + * + * Güncellemede kapatılamaz: geçiş kullanıcı onayı beklemeden yapılır ve pencere + * yeni sürüme geçilene (sayfa yenilenene) kadar açık kalır. İlk kurulumda ise + * (`dismissible`) çalışan sürüm etkilenmediği için kapatılıp arka planda devam + * edilebilir. + * + * Bilerek platform Dialog'u (react-modal + framer-motion) kullanılmadı: bu + * bileşen güncelleme anında lazy yüklenir ve tek başına ~2 KB kalmalı. + */ + +const formatBytes = (bytes: number) => { + if (!bytes) return '0 KB' + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +const formatDuration = (seconds: number) => { + if (seconds < 60) return `${Math.max(1, seconds)} sn` + const minutes = Math.round(seconds / 60) + return `${minutes} dk` +} + +const titleFor = (state: AppUpdateState) => { + switch (state.status) { + case 'downloading': + return state.dismissible ? 'Bileşenler indiriliyor' : 'Yeni sürüm indiriliyor' + case 'completed': + return 'Güncelleme tamamlandı' + case 'ready': + return 'Yeni sürüm hazırlanıyor' + case 'activating': + return 'Uygulama yeniden başlatılıyor' + case 'reload-required': + return 'Yeni sürüm yayınlandı' + case 'error': + return 'Güncelleme tamamlanamadı' + default: + return 'Güncelleme kontrol ediliyor' + } +} + +const countdownText = (state: AppUpdateState) => + state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} sn)` : '' + +const descriptionFor = (state: AppUpdateState) => { + switch (state.status) { + case 'downloading': + return state.dismissible + ? 'Tüm bileşenler indiriliyor; bittiğinde ekranlar anında açılacak. Bu pencereyi kapatıp devam edebilirsiniz.' + : 'Bileşenler indiriliyor, sekmeyi kapatmayın. İndirme bitince yeni sürüme otomatik geçilecek.' + case 'completed': + return 'Tüm bileşenler indirildi, uygulama kullanıma hazır.' + case 'ready': + return 'İndirme tamamlandı, yeni sürüme geçiliyor.' + case 'activating': + return 'Sayfa otomatik olarak yenilenecek.' + case 'reload-required': + return `Yeni sürüme otomatik geçiliyor${countdownText(state)}.` + case 'error': + return `${state.message ?? 'Geçiş tamamlanamadı.'}${countdownText(state)}` + default: + return 'Sunucudaki sürüm bilgisi okunuyor...' + } +} + +/** + * Bir sürümde ~500 bileşen listelenir ve ilerleme mesajları 150 ms'de bir gelir. + * `memo` şart: service worker yalnızca değişen dosyaları yolladığı için + * değişmeyen satırların nesne kimliği korunur ve yeniden çizilmezler. + * `content-visibility` ise görünür alan dışındaki satırların düzen/boyama + * maliyetini sıfırlar. + */ +const FileRow = memo(({ file }: { file: AppUpdateState['files'][number] }) => { + const percent = file.total > 0 ? Math.min(100, Math.round((file.loaded / file.total) * 100)) : 0 + const color = + file.status === 'error' + ? 'bg-red-500' + : file.status === 'done' + ? 'bg-emerald-500' + : 'bg-indigo-500' + + return ( +
  • +
    + + {file.name} + + + {file.status === 'done' + ? formatBytes(file.total || file.loaded) + : `${formatBytes(file.loaded)} / ${file.total ? formatBytes(file.total) : '—'}`} + +
    +
    +
    +
    +
  • + ) +}) + +FileRow.displayName = 'FileRow' + +/** + * Liste sırası: inen dosyalar en üstte, altında en son bitenler, en altta henüz + * sıraya gelmemişler. `seq` yalnızca dosya durum/ilerleme aldığında değiştiği + * için sıralama her bayt tick'inde bozulmaz. + */ +const ACTIVITY_RANK: Record = { downloading: 0, error: 1, done: 2, pending: 3 } + +const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files'][number]) => + ACTIVITY_RANK[a.status] - ACTIVITY_RANK[b.status] || + (b.seq ?? 0) - (a.seq ?? 0) || + a.index - b.index + +const AppUpdateDialog = () => { + const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState) + + if (!state.visible) return null + + const busy = state.status === 'downloading' || state.status === 'ready' + const doneCount = state.files.filter((file) => file.status === 'done').length + const upgrading = !!state.version && state.version !== state.currentVersion + const ordered = state.files.slice().sort(byActivity) + + return ( +
    +
    +
    + {busy && ( + + )} +
    +

    + {titleFor(state)} +

    +

    + {descriptionFor(state)} +

    +
    +
    + +
    + + {upgrading ? 'Mevcut' : 'Sürüm'}: v{state.currentVersion} + + {/* İlk kurulumda geçilecek bir sürüm yok; aynı numarayı iki kez göstermeyelim. */} + {upgrading && ( + <> + + + Yeni: v{state.version} + + + )} +
    + + {(busy || state.files.length > 0) && ( +
    +
    + + {doneCount}/{state.files.length} bileşen + {state.reusedCount > 0 && ` · ${state.reusedCount} bileşen önbellekten`} + {state.status === 'downloading' && state.secondsRemaining !== undefined && ( + <> · kalan ~{formatDuration(state.secondsRemaining)} + )} + + + {formatBytes(state.loadedBytes)} + {state.totalBytes > 0 && ` / ${formatBytes(state.totalBytes)}`} · %{state.percent} + +
    +
    +
    +
    +
    + )} + +
    + {state.files.length > 0 && ( + <> +

    + İndirilen bileşenler +

    +
      + {ordered.map((file) => ( + + ))} +
    + + )} + + {state.changeLog.length > 0 && ( +
    +

    + v{state.version} ile gelen yenilikler +

    +
      + {state.changeLog.map((line, index) => ( +
    • + + {line} +
    • + ))} +
    +
    + )} +
    + +
    + + {state.dismissible + ? 'Pencereyi kapatsanız da indirme arka planda sürer.' + : 'Güncelleme tamamlanana kadar bu pencere kapanmaz.'} + + {state.dismissible ? ( + + ) : ( + (state.status === 'reload-required' || state.status === 'error') && ( + + ) + )} +
    +
    +
    + ) +} + +export default AppUpdateDialog diff --git a/ui/src/views/version/ChangeLog.tsx b/ui/src/views/version/ChangeLog.tsx index a7382aa4..2c48b4c8 100644 --- a/ui/src/views/version/ChangeLog.tsx +++ b/ui/src/views/version/ChangeLog.tsx @@ -6,7 +6,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization' import React, { useEffect, useState } from 'react' import { Helmet } from 'react-helmet' import { FaRegCheckCircle, FaSyncAlt, FaTag } from 'react-icons/fa' -import { checkForAppUpdate } from './swRegistration' +import { checkForAppUpdate, clearPendingChangelog, fetchVersionInfo } from './swRegistration' type Release = { version: string @@ -115,14 +115,12 @@ const Changelog = () => { const { isCheckingUpdate, checkForUpdate } = useManualUpdateCheck() useEffect(() => { - fetch('/version.json?ts=' + Date.now()) - .then((res) => res.json()) - .then((data) => { - if (data?.releases) { - setReleases(data.releases) - } - }) - .catch(() => setReleases([])) + // Bekleyen changelog bayrağı burada tüketilir; kullanıcı değişiklikleri + // gördüğü için sonraki girişlerde tekrar yönlendirilmez. + clearPendingChangelog() + + // Açılışta zaten alınan sürüm bilgisi paylaşılır (ek istek atılmaz). + void fetchVersionInfo().then((info) => setReleases((info?.releases as Release[]) ?? [])) }, []) return ( diff --git a/ui/src/views/version/InstallAppMenuItem.tsx b/ui/src/views/version/InstallAppMenuItem.tsx new file mode 100644 index 00000000..00b1197c --- /dev/null +++ b/ui/src/views/version/InstallAppMenuItem.tsx @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from 'react' +import { FcDownload } from 'react-icons/fc' +import Dropdown from '@/components/ui/Dropdown' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { getInstallState, promptAppInstall, subscribeInstallState } from './installPrompt' + +/** + * Kullanıcı menüsündeki "uygulamayı yükle" satırı. Tarayıcı kurulum teklifi + * vermediyse (zaten kurulu, kriterler sağlanmıyor ya da desteklenmiyor) hiç + * render edilmez. + */ +const InstallAppMenuItem = () => { + const { translate } = useLocalization() + const state = useSyncExternalStore(subscribeInstallState, getInstallState, getInstallState) + + if (!state.canInstall || state.installed) return null + + const value = translate('::App.InstallApp') + const label = !value || value === '::App.InstallApp' ? 'Uygulamayı yükle' : value + + return ( + void promptAppInstall()} + > + + + + + {label} + + + ) +} + +export default InstallAppMenuItem diff --git a/ui/src/views/version/installPrompt.ts b/ui/src/views/version/installPrompt.ts new file mode 100644 index 00000000..f5a0bab5 --- /dev/null +++ b/ui/src/views/version/installPrompt.ts @@ -0,0 +1,87 @@ +/** + * Tarayıcının "uygulamayı yükle" akışı (PWA install). + * + * `beforeinstallprompt` sayfa yüklenirken erken tetiklenir; React ağacı mount + * olmadan yakalanmazsa olay kaybolur ve buton hiç görünmez. Bu yüzden modül + * import edilir edilmez dinleyiciler kurulur (dosya ~1 KB, entry'de kalması + * sorun değil). + */ + +type InstallPromptEvent = Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +export type InstallState = { + /** Tarayıcı kurulum teklifi verdi ve uygulama henüz kurulu değil. */ + canInstall: boolean + /** Uygulama standalone modda çalışıyor (yani kurulu). */ + installed: boolean +} + +let deferredPrompt: InstallPromptEvent | undefined + +const isStandalone = () => { + try { + return ( + window.matchMedia('(display-mode: standalone)').matches || + window.matchMedia('(display-mode: minimal-ui)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true + ) + } catch { + return false + } +} + +let state: InstallState = { canInstall: false, installed: isStandalone() } + +const listeners = new Set<(value: InstallState) => void>() + +export const getInstallState = () => state + +export const subscribeInstallState = (listener: (value: InstallState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function setState(patch: Partial) { + state = { ...state, ...patch } + listeners.forEach((listener) => listener(state)) +} + +export const promptAppInstall = async (): Promise<'accepted' | 'dismissed' | 'unavailable'> => { + const event = deferredPrompt + if (!event) return 'unavailable' + // Yakalanan olay tek kullanımlıktır. + deferredPrompt = undefined + setState({ canInstall: false }) + try { + await event.prompt() + const { outcome } = await event.userChoice + if (outcome === 'accepted') setState({ installed: true }) + return outcome + } catch (error) { + console.warn('Uygulama kurulumu başlatılamadı.', error) + return 'unavailable' + } +} + +if (typeof window !== 'undefined') { + window.addEventListener('beforeinstallprompt', (event) => { + // Varsayılan mini bar yerine uygulama içindeki butonu kullanıyoruz. + event.preventDefault() + deferredPrompt = event as InstallPromptEvent + setState({ canInstall: !isStandalone(), installed: isStandalone() }) + }) + + window.addEventListener('appinstalled', () => { + deferredPrompt = undefined + setState({ canInstall: false, installed: true }) + }) + + window + .matchMedia('(display-mode: standalone)') + .addEventListener?.('change', (event) => + setState({ installed: event.matches, canInstall: event.matches ? false : state.canInstall }), + ) +} diff --git a/ui/src/views/version/mountAppUpdateDialog.tsx b/ui/src/views/version/mountAppUpdateDialog.tsx new file mode 100644 index 00000000..e6e88077 --- /dev/null +++ b/ui/src/views/version/mountAppUpdateDialog.tsx @@ -0,0 +1,21 @@ +import { createRoot, type Root } from 'react-dom/client' +import AppUpdateDialog from './AppUpdateDialog' + +/** + * Güncelleme penceresi uygulama ağacından bağımsız, kendi kökünde mount edilir: + * - Entry chunk'ta taşınmaz (yalnızca güncelleme varken indirilir). + * - Router/Store yeniden render'larından etkilenmez, güncelleme sırasında + * uygulama yeniden yüklense bile pencere ayakta kalır. + */ +let root: Root | undefined + +export const mountAppUpdateDialog = async () => { + if (root) return + const host = document.createElement('div') + host.id = 'app-update-root' + document.body.appendChild(host) + root = createRoot(host) + root.render() +} + +export default mountAppUpdateDialog diff --git a/ui/src/views/version/swRegistration.ts b/ui/src/views/version/swRegistration.ts index bc488359..88434e37 100644 --- a/ui/src/views/version/swRegistration.ts +++ b/ui/src/views/version/swRegistration.ts @@ -1,20 +1,33 @@ import { registerSW } from 'virtual:pwa-register' -import { store } from '@/store' +// `beforeinstallprompt` erken tetiklendiği için dinleyiciler modül yüklenirken kurulur. +import './installPrompt' /** - * Service worker güncelleme akışı + * Sürüm / güncelleme motoru * ------------------------------------------------------------------ - * 1. Kayıt + açılışta bir kez `update()` kontrolü. - * 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri - * geldiğinde tekrar kontrol -> yeni deploy açık uygulamada da yakalanır. - * 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant - * ile "yeni sürüm indiriliyor" bilgisi gösterilir. - * 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir, - * SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir. - * 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi - * ve changelog yönlendirmesi yapılır. + * Tek sürüm kaynağı `public/version.json` (build'de `scripts/generate-version.js` + * üretir). Çalışan bundle'ın `buildId`'si derleme anında gömülür; sunucudaki + * `version.json` ile karşılaştırıldığı için git tag'i atılmasa bile YENİ DEPLOY + * kesin olarak anlaşılır. + * + * Akış: + * 1. Açılışta tek bir `version.json` isteği (sonuç önbelleklenir, diğer + * ekranlar `fetchVersionInfo()` ile aynı sonucu kullanır). + * 2. `buildId` farklıysa service worker güncellemesi tetiklenir. + * 3. Service worker (`src/sw.ts`) indirdiği her dosyayı isim/boyut/yüzde + * olarak yayınlar; bu modül state'e çevirir, pencere (lazy) gösterir. + * 4. Kurulum bitince SKIP_WAITING → `controllerchange` → reload. + * 5. Yeni build ilk kez çalıştığında changelog bayrağı yazılır; login sonrası + * kullanıcı ChangeLog ekranına yönlendirilir. + * + * Bu dosya entry chunk'ındadır: React/UI bağımlılığı yoktur, pencere ve + * bildirim bileşenleri yalnızca gerçekten güncelleme varken lazy yüklenir. */ +/* ------------------------------------------------------------------ */ +/* Tipler */ +/* ------------------------------------------------------------------ */ + export type ManualUpdateResult = | 'up-to-date' | 'updating' @@ -23,15 +36,108 @@ export type ManualUpdateResult = /** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */ | 'disabled' -const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000 -const MIN_CHECK_GAP = 60 * 1000 +export type UpdateFileStatus = 'pending' | 'downloading' | 'done' | 'error' + +export type UpdateFile = { + /** Service worker'ın gönderdiği liste içindeki sırası (delta birleştirmede anahtar). */ + index: number + url: string + name: string + loaded: number + total: number + status: UpdateFileStatus + /** Son hareket sırası; liste en yeni hareketi en üstte gösterir. */ + seq?: number +} + +export type AppUpdateStatus = + | 'idle' + | 'checking' + | 'downloading' + | 'ready' + /** Tüm bileşenler indi ve geçiş gerekmiyor (ilk kurulum). */ + | 'completed' + | 'activating' + | 'reload-required' + | 'error' + +export type AppUpdateState = { + status: AppUpdateStatus + /** Pencere açık mı. */ + visible: boolean + /** Sunucudaki (yeni) sürüm. */ + version?: string + /** Çalışan bundle'ın sürümü. */ + currentVersion: string + changeLog: string[] + files: UpdateFile[] + loadedBytes: number + totalBytes: number + percent: number + /** Değişmediği için yeniden indirilmeyen dosya sayısı. */ + reusedCount: number + message?: string + /** Otomatik geçiş/yeniden deneme için kalan saniye. */ + secondsLeft?: number + /** İndirme hızından tahmin edilen kalan süre (saniye). */ + secondsRemaining?: number + /** + * Pencere kullanıcı tarafından kapatılabilir mi? İlk kurulumda indirme + * çalışan sürümü etkilemediği için kullanıcı kapatıp devam edebilir; + * güncellemede ise geçiş bitene kadar açık kalır. + */ + dismissible: boolean +} + +export type Release = { + version: string + buildDate?: string + commit?: string + changeLog?: string[] +} + +export type VersionInfo = { + buildId: string + version: string + buildTime?: string + buildDate?: string + commit?: string + releases: Release[] +} + +/* ------------------------------------------------------------------ */ +/* Sabitler */ +/* ------------------------------------------------------------------ */ + +declare const __APP_BUILD_ID__: string +declare const __APP_VERSION__: string + +const RUNNING_BUILD_ID = typeof __APP_BUILD_ID__ === 'string' ? __APP_BUILD_ID__ : 'dev' +const RUNNING_VERSION = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '0.0.0' + +/** Sekme açık kalırsa periyodik yoklama. */ +const VERSION_POLL_INTERVAL = 30 * 60 * 1000 +/** Aynı version.json sonucunun paylaşıldığı süre (açılışta tek istek). */ +const VERSION_CACHE_TTL = 60 * 1000 +/** İki deploy yoklaması arasındaki en kısa süre (sekmeye dönüş, online vb.). */ +const MIN_VERSION_CHECK_GAP = 5 * 60 * 1000 +const MIN_CHECK_GAP = 30 * 1000 const ACTIVATION_RETRY_DELAY = 8_000 -const ACTIVATION_TIMEOUT = 30_000 -// `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir -// callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır. +const ACTIVATION_TIMEOUT = 45_000 const REGISTRATION_TIMEOUT = 10_000 -// `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor. const UPDATE_CALL_TIMEOUT = 20_000 +/** Yenileme gerektiren deploy'da otomatik geçişe kalan süre. */ +const AUTO_RELOAD_SECONDS = 5 +/** Kurulum/aktivasyon başarısız olduğunda otomatik yeniden deneme süresi. */ +const RETRY_DELAY_SECONDS = 15 + +const LAST_BUILD_KEY = 'sozsoft:last-build-id' +const AUTO_RELOAD_GUARD_KEY = 'sozsoft:auto-reload-build' +const PENDING_CHANGELOG_KEY = 'sozsoft:pending-changelog' +const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload' + +/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */ +const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true' const withTimeout = (promise: Promise, ms: number) => Promise.race([ @@ -39,369 +145,219 @@ const withTimeout = (promise: Promise, ms: number) => new Promise((resolve) => window.setTimeout(() => resolve(undefined), ms)), ]) -const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version' -const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload' - -type Release = { - version: string - changeLog?: string[] +const readStorage = (key: string, session = false) => { + try { + return (session ? sessionStorage : localStorage).getItem(key) ?? undefined + } catch { + return undefined + } } +const writeStorage = (key: string, value: string | undefined, session = false) => { + try { + const store = session ? sessionStorage : localStorage + if (value === undefined) store.removeItem(key) + else store.setItem(key, value) + } catch { + /* storage kapalıysa yoksay */ + } +} + +/* ------------------------------------------------------------------ */ +/* Durum yönetimi */ +/* ------------------------------------------------------------------ */ + +let state: AppUpdateState = { + status: 'idle', + visible: false, + currentVersion: RUNNING_VERSION, + changeLog: [], + files: [], + loadedBytes: 0, + totalBytes: 0, + percent: 0, + reusedCount: 0, + dismissible: false, +} + +const listeners = new Set<(value: AppUpdateState) => void>() + +export const getAppUpdateState = () => state + +export const subscribeAppUpdate = (listener: (value: AppUpdateState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function setState(patch: Partial) { + state = { ...state, ...patch } + listeners.forEach((listener) => listener(state)) +} + +/* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */ +let rateStartedAt = 0 +let rateStartBytes = 0 + +const resetRate = () => { + rateStartedAt = 0 + rateStartBytes = 0 +} + +function estimateRemaining(loadedBytes: number, totalBytes: number) { + const now = Date.now() + if (!rateStartedAt) { + rateStartedAt = now + rateStartBytes = loadedBytes + return undefined + } + const elapsed = now - rateStartedAt + const delta = loadedBytes - rateStartBytes + // İlk saniyelerde hız oturmadığı için tahmin verilmez. + if (elapsed < 2_000 || delta <= 0 || totalBytes <= 0) return state.secondsRemaining + const remaining = Math.max(0, totalBytes - loadedBytes) + return Math.round(remaining / (delta / elapsed) / 1000) +} + +/** + * Boyutlar derleme anında bilindiği için toplamlar service worker'dan gelir; + * gelmezse (eski worker) dosya listesinden hesaplanır. + */ +function recalculate( + files: UpdateFile[], + totals?: { loadedBytes?: number; totalBytes?: number }, + extra?: Partial, +) { + const loadedBytes = totals?.loadedBytes ?? files.reduce((sum, file) => sum + file.loaded, 0) + const totalBytes = + totals?.totalBytes || files.reduce((sum, file) => sum + (file.total || file.loaded), 0) + + // Boyut hiç bilinmiyorsa (warmup.json yoksa) biten dosya sayısına düşülür. + const ratio = + totalBytes > 0 + ? loadedBytes / totalBytes + : files.filter((file) => file.status === 'done').length / (files.length || 1) + const finished = + state.status === 'ready' || state.status === 'activating' || state.status === 'completed' + + setState({ + files, + loadedBytes, + totalBytes, + // Yüzde asla geri gitmesin. + percent: finished ? 100 : Math.max(state.percent, Math.min(99, Math.round(ratio * 100))), + secondsRemaining: finished ? undefined : estimateRemaining(loadedBytes, totalBytes), + ...extra, + }) +} + +/* ------------------------------------------------------------------ */ +/* Sürüm bilgisi */ +/* ------------------------------------------------------------------ */ + +let versionInfoPromise: Promise | undefined +let inFlightVersionRequest: Promise | undefined +let lastVersionFetchAt = 0 +let latestInfo: VersionInfo | undefined + +const normalize = (raw: unknown): VersionInfo | undefined => { + const data = raw as Partial | undefined + if (!data) return undefined + const releases = Array.isArray(data.releases) ? data.releases : [] + const version = data.version ?? releases[0]?.version + if (!version) return undefined + return { + buildId: String(data.buildId ?? data.commit ?? version), + version, + buildTime: data.buildTime, + buildDate: data.buildDate ?? releases[0]?.buildDate, + commit: data.commit, + releases, + } +} + +/** + * `version.json` — tek sürüm kaynağı (deploy yoklaması + changelog). + * + * Uçuşta bir istek varsa ikinci bir ağ isteği atılmaz; `force` verilmediğinde + * kısa süre içinde (TTL) alınan sonuç paylaşılır. Böylece açılışta motor, + * pencere ve ChangeLog ekranı tek isteği kullanır. + */ +export const fetchVersionInfo = (force = false): Promise => { + if (inFlightVersionRequest) return inFlightVersionRequest + if (!force && versionInfoPromise && Date.now() - lastVersionFetchAt < VERSION_CACHE_TTL) { + return versionInfoPromise + } + + inFlightVersionRequest = fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) + .then((response) => { + if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`) + return response.json() + }) + .then((data) => { + const info = normalize(data) + if (info) { + latestInfo = info + lastVersionFetchAt = Date.now() + } + return info + }) + .catch((error: unknown) => { + console.warn('Sürüm bilgisi okunamadı.', error) + versionInfoPromise = undefined + return undefined + }) + .finally(() => { + inFlightVersionRequest = undefined + }) + + versionInfoPromise = inFlightVersionRequest + return inFlightVersionRequest +} + +export const getRunningVersion = () => RUNNING_VERSION +export const getLatestVersionInfo = () => latestInfo + +/** Login sonrası changelog yönlendirmesi için bayrak. */ +export const hasPendingChangelog = () => !!readStorage(PENDING_CHANGELOG_KEY) + +export const consumePendingChangelog = (): string | undefined => { + const value = readStorage(PENDING_CHANGELOG_KEY) + if (value) writeStorage(PENDING_CHANGELOG_KEY, undefined) + return value +} + +export const clearPendingChangelog = () => writeStorage(PENDING_CHANGELOG_KEY, undefined) + +/** + * Yeni bir build ilk kez çalıştığında (güncelleme sonrası ya da kullanıcı + * uygulamaya deploy'dan sonra ilk kez girdiğinde) changelog bayrağını yazar. + */ +function markRunningBuildSeen(version: string) { + const lastSeen = readStorage(LAST_BUILD_KEY) + if (lastSeen && lastSeen !== RUNNING_BUILD_ID) { + writeStorage(PENDING_CHANGELOG_KEY, version) + } + if (lastSeen !== RUNNING_BUILD_ID) writeStorage(LAST_BUILD_KEY, RUNNING_BUILD_ID) +} + +/* ------------------------------------------------------------------ */ +/* Service worker kaydı ve güncelleme */ +/* ------------------------------------------------------------------ */ + let started = false let registration: ServiceWorkerRegistration | undefined let initialization: Promise | undefined let activating = false let reloading = false let lastCheckAt = 0 -let periodicTimer: number | undefined +let lastVersionCheckAt = 0 +let pollTimer: number | undefined let activationRetryTimer: number | undefined let activationTimeoutTimer: number | undefined -let storeUnsubscribe: (() => void) | undefined -let latestVersion: string | undefined -let releasePromise: Promise | undefined const watchedRegistrations = new WeakSet() const watchedWorkers = new WeakSet() -/* ------------------------------------------------------------------ */ -/* Sürüm bilgisi */ -/* ------------------------------------------------------------------ */ - -const loadLatestRelease = () => - (releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) - .then((response) => { - if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`) - return response.json() as Promise<{ releases?: Release[] }> - }) - .then((data) => { - const release = data.releases?.[0] - latestVersion = release?.version ?? latestVersion - return release - }) - .catch((error: unknown) => { - console.warn('Sürüm bilgisi okunamadı.', error) - releasePromise = undefined - return undefined - })) - -/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */ -export const consumeAppUpdatedVersion = (): string | undefined => { - try { - const value = sessionStorage.getItem(UPDATED_VERSION_KEY) - if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY) - return value ?? undefined - } catch { - return undefined - } -} - -/* ------------------------------------------------------------------ */ -/* Arayüz */ -/* ------------------------------------------------------------------ */ - -const STYLE_ID = 'sw-update-style' -const BANNER_ID = 'sw-update-banner' -const OVERLAY_ID = 'sw-update-overlay' - -function ensureStyles() { - if (document.getElementById(STYLE_ID)) return - - const style = document.createElement('style') - style.id = STYLE_ID - style.textContent = ` - #${BANNER_ID} { - position: fixed; - right: 16px; - bottom: 16px; - z-index: 99998; - display: flex; - align-items: center; - gap: 10px; - max-width: 320px; - padding: 12px 16px; - border-radius: 12px; - background: #111827; - color: #f9fafb; - font-size: 13px; - line-height: 1.4; - box-shadow: 0 10px 30px rgba(0,0,0,0.3); - } - #${OVERLAY_ID} { - position: fixed; - inset: 0; - z-index: 99999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - } - .sw-update-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - background: #fff; - border-radius: 16px; - padding: 32px 40px; - box-shadow: 0 8px 40px rgba(0,0,0,0.25); - text-align: center; - max-width: 560px; - max-height: min(90vh, 720px); - width: 90%; - } - .sw-update-spinner { - flex: 0 0 auto; - width: 56px; - height: 56px; - border: 5px solid #e5e7eb; - border-top-color: #6366f1; - border-radius: 50%; - animation: sw-spin 0.8s linear infinite; - } - #${BANNER_ID} .sw-update-spinner { - width: 20px; - height: 20px; - border-width: 3px; - border-color: rgba(255,255,255,0.25); - border-top-color: #a5b4fc; - } - @keyframes sw-spin { to { transform: rotate(360deg); } } - .sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; } - .sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; } - .sw-update-versions { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 6px; - width: 100%; - padding: 10px 12px; - border-radius: 8px; - background: #f3f4f6; - color: #4b5563; - font-size: 13px; - } - .sw-update-separator { color: #9ca3af; margin: 0 4px; } - .sw-update-release { - width: 100%; - min-height: 0; - overflow-y: auto; - text-align: left; - border-top: 1px solid #e5e7eb; - padding-top: 14px; - } - .sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; } - .sw-update-list { - display: flex; - flex-direction: column; - gap: 8px; - list-style: none; - padding: 0; - margin: 0; - color: #6b7280; - font-size: 13px; - line-height: 1.45; - } - .sw-update-list li { display: flex; align-items: flex-start; gap: 8px; } - .sw-update-check { - display: inline-flex; - flex: 0 0 16px; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - margin-top: 1px; - border-radius: 50%; - background: #d1fae5; - color: #059669; - font-size: 11px; - font-weight: 700; - } - .sw-update-empty { display: block; color: #9ca3af; } - .sw-update-action { - appearance: none; - border: 0; - cursor: pointer; - padding: 10px 20px; - border-radius: 8px; - background: #4f46e5; - color: #fff; - font-size: 14px; - font-weight: 600; - } - .sw-update-action:hover { background: #4338ca; } - @media (max-width: 640px) { - .sw-update-card { padding: 24px 20px; } - #${BANNER_ID} { left: 16px; right: 16px; max-width: none; } - } - ` - document.head.appendChild(style) -} - -function removeStyles() { - document.getElementById(STYLE_ID)?.remove() -} - -/** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */ -function showDownloadBanner() { - if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return - ensureStyles() - - const banner = document.createElement('div') - banner.id = BANNER_ID - banner.setAttribute('role', 'status') - banner.setAttribute('aria-live', 'polite') - - const spinner = document.createElement('div') - spinner.className = 'sw-update-spinner' - - const text = document.createElement('span') - text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...' - - banner.append(spinner, text) - document.body.appendChild(banner) -} - -function removeDownloadBanner() { - document.getElementById(BANNER_ID)?.remove() -} - -function getText(value: unknown) { - return value === undefined || value === null ? undefined : String(value) -} - -function applyApiVersion(overlay: HTMLElement) { - const apiConfig = store.getState().abpConfig.config?.extraProperties - const environment = getText(apiConfig?.environment) - const version = getText(apiConfig?.version) - if (!environment && !version) return false - - const target = overlay.querySelector('[data-sw-api-version]') - if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}` - return true -} - -function watchApiVersion(overlay: HTMLElement) { - storeUnsubscribe?.() - storeUnsubscribe = undefined - if (applyApiVersion(overlay)) return - - storeUnsubscribe = store.subscribe(() => { - if (!overlay.isConnected || applyApiVersion(overlay)) { - storeUnsubscribe?.() - storeUnsubscribe = undefined - } - }) -} - -async function fillOverlayDetails(overlay: HTMLElement) { - const release = await loadLatestRelease() - if (!overlay.isConnected) return - - const uiVersion = release?.version ?? store.getState().locale.currentUiVersion - const uiValue = overlay.querySelector('[data-sw-ui-version]') - if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}` - watchApiVersion(overlay) - - const releaseTitle = overlay.querySelector('[data-sw-release-title]') - const releaseList = overlay.querySelector('[data-sw-release-list]') - if (!releaseTitle || !releaseList) return - - releaseTitle.textContent = release?.version - ? `v${release.version} ile gelen yenilikler` - : 'Sürüm notları' - releaseList.replaceChildren() - - const changes = release?.changeLog?.filter(Boolean) ?? [] - if (changes.length === 0) { - const item = document.createElement('li') - item.className = 'sw-update-empty' - item.textContent = 'Bu sürüm için not bulunamadı.' - releaseList.appendChild(item) - return - } - - changes.forEach((change) => { - const item = document.createElement('li') - const icon = document.createElement('span') - const text = document.createElement('span') - icon.className = 'sw-update-check' - icon.setAttribute('aria-hidden', 'true') - icon.textContent = '✓' - text.textContent = change.replace(/^\s*-\s*/, '') - item.append(icon, text) - releaseList.appendChild(item) - }) -} - -function showActivationOverlay() { - removeDownloadBanner() - if (document.getElementById(OVERLAY_ID)) return - ensureStyles() - - const overlay = document.createElement('div') - overlay.id = OVERLAY_ID - overlay.setAttribute('role', 'status') - overlay.setAttribute('aria-live', 'polite') - overlay.innerHTML = ` -
    -
    -

    Sistem güncelleniyor

    -

    Yeni sürüm yükleniyor, lütfen bekleyin...
    Sayfa otomatik olarak yenilenecek.

    -
    - UI: Yükleniyor... - | - API: Yükleniyor... -
    -
    -

    Sürüm notları

    -
      -
    • Sürüm notları yükleniyor...
    • -
    -
    -
    - ` - document.body.appendChild(overlay) - void fillOverlayDetails(overlay) -} - -/** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */ -function showManualReloadPrompt() { - const overlay = document.getElementById(OVERLAY_ID) - if (!overlay) return - - overlay.querySelector('.sw-update-spinner')?.remove() - - const title = overlay.querySelector('[data-sw-title]') - const desc = overlay.querySelector('[data-sw-desc]') - if (title) title.textContent = 'Güncelleme tamamlanamadı' - if (desc) { - desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.' - } - - if (overlay.querySelector('.sw-update-action')) return - const button = document.createElement('button') - button.type = 'button' - button.className = 'sw-update-action' - button.textContent = 'Sayfayı yenile' - button.addEventListener('click', () => reloadForUpdate()) - overlay.querySelector('.sw-update-card')?.appendChild(button) -} - -function teardownUpdateUi() { - storeUnsubscribe?.() - storeUnsubscribe = undefined - removeDownloadBanner() - document.getElementById(OVERLAY_ID)?.remove() - removeStyles() -} - -/* ------------------------------------------------------------------ */ -/* Aktivasyon */ -/* ------------------------------------------------------------------ */ - function clearActivationTimers() { window.clearTimeout(activationRetryTimer) window.clearTimeout(activationTimeoutTimer) @@ -413,27 +369,122 @@ function reloadForUpdate() { if (reloading) return reloading = true clearActivationTimers() - try { - if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion) - } catch { - /* sessionStorage kullanılamıyorsa bildirim atlanır */ - } window.location.reload() } +/** src/sw.ts içindeki kabuk cache adı ile aynı olmalı. */ +const SHELL_CACHE = 'sozsoft-shell-v1' + +/** + * Service worker güncellenmediği (ör. yalnızca lazy chunk değişen) deploy'larda + * kabuk cache'i temizlenmeden yenilenirse aynı eski index.html geri gelir ve + * güncelleme penceresi sonsuz döngüye girer. + */ +async function reloadWithFreshShell() { + try { + await caches.delete(SHELL_CACHE) + } catch { + /* cache API kapalıysa normal yenileme yapılır */ + } + reloadForUpdate() +} + +/* ------------------------------------------------------------------ */ +/* Otomatik geçiş */ +/* ------------------------------------------------------------------ */ + +let autoTimer: number | undefined + +function clearAutoAction() { + window.clearInterval(autoTimer) + autoTimer = undefined +} + +/** + * Güncelleme kullanıcı müdahalesi beklemeden tamamlanır; geri sayım yalnızca + * ne olacağını göstermek içindir. Pencere geçiş bitene kadar kapanmaz. + */ +function scheduleAutoAction(seconds: number, action: () => void) { + clearAutoAction() + pendingAutoAction = action + setState({ secondsLeft: seconds }) + autoTimer = window.setInterval(() => { + const left = (state.secondsLeft ?? 0) - 1 + if (left > 0) { + setState({ secondsLeft: left }) + return + } + clearAutoAction() + setState({ secondsLeft: 0 }) + action() + }, 1000) +} + +let pendingAutoAction: (() => void) | undefined + +/** Yeni sürüme geçişi yeniden dener (kurulum/aktivasyon başarısız olduğunda). */ +async function retryUpdate() { + clearAutoAction() + + if (!navigator.onLine) { + // Çevrimdışıyken kabuk cache'ini silmek uygulamayı hiç açılamaz hale + // getirir; bağlantı gelince `online` dinleyicisi akışı sürdürür. + setState({ + status: 'error', + secondsLeft: undefined, + message: 'Bağlantı bekleniyor. İnternet geldiğinde güncelleme sürdürülecek.', + }) + return + } + + activating = false + clearActivationTimers() + setState({ + status: 'checking', + message: undefined, + files: [], + loadedBytes: 0, + totalBytes: 0, + percent: 0, + secondsLeft: undefined, + }) + + if (registration) { + await runUpdateCheck(true) + if (state.status === 'checking') void reloadWithFreshShell() + return + } + void reloadWithFreshShell() +} + +/** Geri sayımı beklemeden çalıştırır (penceredeki tek buton). */ +export const applyAppUpdate = () => { + const action = pendingAutoAction + clearAutoAction() + setState({ secondsLeft: 0 }) + + if (action) { + action() + return + } + if (registration?.waiting) { + activateWaitingWorker() + return + } + void reloadWithFreshShell() +} + function postSkipWaiting() { registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) } function activateWaitingWorker() { if (activating) { - showActivationOverlay() + setState({ visible: true }) return } activating = true - showActivationOverlay() - // Sürüm numarasını yenileme öncesinde hazır tut. - void loadLatestRelease() + setState({ status: 'activating', visible: true, percent: 100 }) navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true }) postSkipWaiting() @@ -453,61 +504,254 @@ function activateWaitingWorker() { } console.warn('Service worker aktivasyonu zaman aşımına uğradı.') - showManualReloadPrompt() + setState({ + status: 'error', + visible: true, + message: 'Geçiş beklenenden uzun sürdü, yeniden deneniyor.', + }) + scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) }, ACTIVATION_TIMEOUT) } /* ------------------------------------------------------------------ */ -/* Güncelleme takibi */ +/* Service worker mesajları (indirme ilerlemesi) */ /* ------------------------------------------------------------------ */ -function trackInstallingWorker(reg: ServiceWorkerRegistration) { - const worker = reg.installing - // Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur. - if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return +type SwFile = { + index?: number + url: string + name: string + loaded: number + total: number + status: string +} - watchedWorkers.add(worker) - showDownloadBanner() +type SwProgressMessage = { + type?: string + /** Tam liste (START / DONE). */ + files?: SwFile[] + /** Yalnızca değişen dosyalar (PROGRESS). */ + updates?: SwFile[] + reusedCount?: number + loadedBytes?: number + totalBytes?: number + /** 'install': yeni sürüm kurulumu, 'warmup': eksik bileşenlerin tamamlanması. */ + phase?: 'install' | 'warmup' +} - worker.addEventListener('statechange', () => { - if (worker.state === 'installed') { - activateWaitingWorker() - return - } - // Precache dosyalarından biri indirilemezse worker redundant olur; eski - // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz. - if (worker.state === 'redundant' && !activating) { - console.warn('Yeni service worker kurulamadı, mevcut sürümle devam ediliyor.') - teardownUpdateUi() +const toFiles = (files: SwFile[] | undefined): UpdateFile[] => + (files ?? []).map((file, position) => ({ + index: file.index ?? position, + url: file.url, + name: file.name, + loaded: file.loaded ?? 0, + total: file.total ?? 0, + status: (file.status as UpdateFileStatus) ?? 'pending', + })) + +let updateSeq = 0 + +/** + * İlerleme mesajları yalnızca değişen dosyaları taşır (bir sürümde ~500 chunk + * var; tam listeyi saniyede birkaç kez göndermek/kopyalamak pahalı). Her + * güncellenen dosyaya artan bir sıra numarası verilir; pencere listeyi buna + * göre en yeni hareket en üstte olacak şekilde diziyor. + */ +function mergeFiles(updates: UpdateFile[]): UpdateFile[] { + if (updates.length === 0) return state.files + const merged = state.files.slice() + for (const update of updates) { + const item = { ...update, seq: ++updateSeq } + if (update.index >= 0 && update.index < merged.length) merged[update.index] = item + else merged.push(item) + } + return merged +} + +/** Tam liste geldiğinde (DONE) mevcut sıralama bilgisi korunur. */ +const keepSeq = (files: UpdateFile[]): UpdateFile[] => + files.map((file, position) => ({ ...file, seq: state.files[position]?.seq })) + +/** Tamamlandı/hata mesajının ekranda kaldığı süre (geçiş gerekmeyen kurulumda). */ +const NOTICE_VISIBLE_MS = 5_000 + +function hideDialogLater() { + window.setTimeout(() => { + if (state.status === 'completed' || state.status === 'error') { + setState({ visible: false, status: 'idle' }) } + }, NOTICE_VISIBLE_MS) +} + +function handleWorkerMessage(event: MessageEvent) { + const data = event.data as SwProgressMessage | undefined + if (!data?.type) return + + /** + * Controller yoksa bu bir güncelleme değil, uygulamanın ilk kez hazırlanmasıdır + * (yeni kullanıcı, temizlenmiş cache, yeni tarayıcı). Bileşenler yine de + * indirilir ve ilerleme gösterilir; ancak çalışan sürüm zaten yeni olduğu için + * sonunda geçiş/yenileme yapılmaz, pencere kapatılabilir. + */ + const controlled = !!navigator.serviceWorker.controller + + /** + * Isıtma (eksik bileşenlerin tamamlanması) çalışan sürümü değiştirmez: sonunda + * geçiş/yenileme yapılmaz ve pencere kapatılabilir. Kurulum ise yeni sürüme + * geçişle biter. + */ + const transitions = controlled && data.phase !== 'warmup' + + const totals = { loadedBytes: data.loadedBytes, totalBytes: data.totalBytes } + + switch (data.type) { + // İndirme sürerken bağlanan istemcinin aldığı anlık durum; START ile aynı + // yükü taşır (bkz. requestPrecacheState). + case 'SW_PRECACHE_STATE': + case 'SW_PRECACHE_START': + // İndirme başladı: bekleyen geri sayım varsa iptal edilir. + clearAutoAction() + void showUpdateDialog('downloading') + resetRate() + setState({ percent: 0 }) + recalculate(toFiles(data.files), totals, { + status: 'downloading', + visible: true, + reusedCount: data.reusedCount ?? 0, + secondsLeft: undefined, + dismissible: !transitions, + }) + break + case 'SW_PRECACHE_PROGRESS': + // Durum isteği bir yarışa takılıp cevapsız kalırsa pencere burada açılır. + if (!dialogMountRequested) void showUpdateDialog('downloading') + // `files`: yalnızca eski service worker'lar (tam liste) için. + recalculate(mergeFiles(toFiles(data.updates ?? data.files)), totals, { + status: 'downloading', + visible: true, + dismissible: !transitions, + }) + break + case 'SW_PRECACHE_DONE': + recalculate(keepSeq(toFiles(data.files)), totals, { + // Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti. + status: transitions ? 'ready' : 'completed', + visible: true, + percent: 100, + }) + if (!transitions) hideDialogLater() + break + case 'SW_PRECACHE_ERROR': + if (!transitions) { + // Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma. + setState({ + status: 'error', + visible: true, + dismissible: true, + message: 'Bileşenler tamamen indirilemedi, gerektikçe yüklenecek.', + }) + hideDialogLater() + break + } + setState({ + status: 'error', + visible: true, + message: 'Yeni sürüm indirilemedi, yeniden deneniyor.', + }) + scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) + break + default: + break + } +} + +/** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */ +export const dismissAppUpdateDialog = () => setState({ visible: false }) + +/* ------------------------------------------------------------------ */ +/* Güncelleme penceresi (lazy) */ +/* ------------------------------------------------------------------ */ + +let dialogMountRequested = false + +/** + * Pencere bileşeni entry chunk'ında taşınmaz; yalnızca gerçekten güncelleme + * olduğunda indirilir ve body'ye kendi kökünde mount edilir. + */ +async function showUpdateDialog(status: AppUpdateStatus) { + setState({ status, visible: true }) + if (dialogMountRequested) return + dialogMountRequested = true + + try { + const { mountAppUpdateDialog } = await import('./mountAppUpdateDialog') + await mountAppUpdateDialog() + } catch (error) { + dialogMountRequested = false + console.warn('Güncelleme penceresi yüklenemedi.', error) + } +} + +/* ------------------------------------------------------------------ */ +/* Sürüm kontrolü */ +/* ------------------------------------------------------------------ */ + +function applyVersionInfo(info: VersionInfo) { + const release = info.releases[0] + setState({ + version: info.version, + changeLog: release?.changeLog?.filter(Boolean) ?? [], }) } -function watchRegistration(reg: ServiceWorkerRegistration) { - if (watchedRegistrations.has(reg)) return - watchedRegistrations.add(reg) - reg.addEventListener('updatefound', () => trackInstallingWorker(reg)) - trackInstallingWorker(reg) -} - /** - * Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı - * değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı - * "unsupported MIME type" SecurityError'ı fırlatır. Bu durumda eski kayıt - * kalıcı olarak bozuktur; temizlenmezse her kontrol aynı hatayı verir. + * Sunucudaki build çalışan build'den farklı mı? + * + * Dev'de karşılaştırma yapılmaz: `__APP_BUILD_ID__` vite config yüklenirken + * gömüldüğü için `version.json` yeniden üretildiğinde sunucu yeniden + * başlatılana kadar kalıcı olarak farklı görünür ve sonsuz yenileme oluşur. */ -async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) { - const isStaleScript = - error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message)) - if (!isStaleScript) return false +const isNewDeploy = (info: VersionInfo | undefined) => + !!info && import.meta.env.PROD && RUNNING_BUILD_ID !== 'dev' && info.buildId !== RUNNING_BUILD_ID - console.warn('Geçersiz service worker kaydı temizleniyor.', error) - try { - await reg.unregister() - } catch { - /* temizlenemediyse bir sonraki açılışta tekrar denenir */ +/** Deploy yoklaması: `version.json` okunur (sürüm + changelog aynı dosyada). */ +async function checkVersionEndpoint(force = false) { + const now = Date.now() + if (now - lastVersionCheckAt < (force ? MIN_CHECK_GAP : MIN_VERSION_CHECK_GAP)) return false + lastVersionCheckAt = now + + const info = await fetchVersionInfo(force) + if (!info) return false + applyVersionInfo(info) + + if (!isNewDeploy(info)) return false + + const requireReload = () => { + void showUpdateDialog('reload-required') + + // Yenileme sonrası hâlâ aynı build çalışıyorsa (ör. sunucu eski dosyayı + // servis etmeye devam ediyorsa) otomatik yenilemeyi tekrarlamak sonsuz + // döngü demektir; bu durumda karar kullanıcıya bırakılır. + if (readStorage(AUTO_RELOAD_GUARD_KEY, true) === info.buildId) { + setState({ + secondsLeft: undefined, + message: 'Yeni sürüme geçilemedi. Yenilemeyi tekrar deneyebilirsiniz.', + }) + return + } + writeStorage(AUTO_RELOAD_GUARD_KEY, info.buildId, true) + // Kullanıcı onayı beklenmez; kısa bir bilgilendirmeden sonra otomatik geçilir. + scheduleAutoAction(AUTO_RELOAD_SECONDS, () => void reloadWithFreshShell()) + } + + if (isServiceWorkerEnabled && 'serviceWorker' in navigator) { + await runUpdateCheck(true) + // Service worker aynı kabuk dosyalarını görüyorsa (ör. yalnızca lazy chunk + // değişmiş) kurulum tetiklenmez; bu durumda sayfa yenilemesi yeterlidir. + if (state.status === 'idle' || state.status === 'checking') requireReload() + } else { + requireReload() } - if (registration === reg) registration = undefined return true } @@ -529,16 +773,116 @@ async function runUpdateCheck(force = false) { } } -function startUpdateWatchers() { - window.clearInterval(periodicTimer) - periodicTimer = window.setInterval(() => { - if (document.visibilityState === 'visible') void runUpdateCheck() - }, PERIODIC_CHECK_INTERVAL) +/** + * Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı + * değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı + * "unsupported MIME type" SecurityError'ı fırlatır. Bu kayıt kalıcı olarak + * bozuktur; temizlenmezse her kontrol aynı hatayı verir. + */ +async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) { + const isStaleScript = + error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message)) + if (!isStaleScript) return false + + console.warn('Geçersiz service worker kaydı temizleniyor.', error) + try { + await reg.unregister() + } catch { + /* temizlenemediyse bir sonraki açılışta tekrar denenir */ + } + if (registration === reg) registration = undefined + return true +} + +/** + * Kurulum çoğu zaman sayfa gezinmesiyle, uygulama mount olmadan başlar; bu + * durumda `SW_PRECACHE_START` kaçar. Kayıt görülür görülmez worker'dan anlık + * ilerleme istenir ki pencere ortadan da açılabilsin. + */ +function requestPrecacheState(reg: ServiceWorkerRegistration) { + const worker = reg.installing ?? reg.waiting ?? reg.active + worker?.postMessage({ type: 'GET_PRECACHE_STATE' }) +} + +/** + * Eksik bileşenlerin tamamlanmasını ister. + * + * Isıtmayı yalnızca kuruluma bağlamak yetmiyor: süre bütçesi dolduğunda, sekme + * kapandığında ya da bir dosya hata verdiğinde kalan chunk'lar bir sonraki + * deploy'a kadar indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu. + * Worker eksik bir şey yoksa hiçbir mesaj yollamaz, pencere de açılmaz. + */ +function requestWarmup(reg: ServiceWorkerRegistration) { + // Kurulum sürüyorsa zaten indiriliyor; ikinci bir oturum başlatma. + if (reg.installing) return + const worker = reg.active ?? navigator.serviceWorker.controller + worker?.postMessage({ type: 'ENSURE_WARMUP' }) +} + +/** İlk boyama ve açılış istekleri bitsin diye ısıtma isteği boşta gönderilir. */ +function scheduleWarmup(reg: ServiceWorkerRegistration) { + const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void }) + .requestIdleCallback + const send = () => requestWarmup(reg) + if (idle) idle.call(window, send, { timeout: 5_000 }) + else window.setTimeout(send, 3_000) +} + +function trackInstallingWorker(reg: ServiceWorkerRegistration) { + const worker = reg.installing + // Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur. + if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return + + watchedWorkers.add(worker) + void showUpdateDialog('downloading') + + worker.addEventListener('statechange', () => { + if (worker.state === 'installed') { + activateWaitingWorker() + return + } + // Precache dosyalarından biri indirilemezse worker redundant olur; eski + // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz. + if (worker.state === 'redundant' && !activating) { + console.warn('Yeni service worker kurulamadı, yeniden denenecek.') + setState({ + status: 'error', + visible: true, + message: 'Yeni sürüm kurulamadı, yeniden deneniyor.', + }) + scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) + } + }) +} + +function watchRegistration(reg: ServiceWorkerRegistration) { + if (watchedRegistrations.has(reg)) return + watchedRegistrations.add(reg) + reg.addEventListener('updatefound', () => { + trackInstallingWorker(reg) + requestPrecacheState(reg) + }) + trackInstallingWorker(reg) + requestPrecacheState(reg) +} + +function startWatchers() { + // Yoklama olay tabanlıdır (sekmeye dönüş / bağlantı). Periyodik zamanlayıcı + // yalnızca sekme uzun süre açık ve önde kalırsa devreye girer. + window.clearInterval(pollTimer) + pollTimer = window.setInterval(() => { + if (document.visibilityState === 'visible') void checkVersionEndpoint() + }, VERSION_POLL_INTERVAL) document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') void runUpdateCheck() + if (document.visibilityState !== 'visible') return + void checkVersionEndpoint() + }) + window.addEventListener('online', () => { + // Çevrimdışıyken beklemeye alınan güncelleme bağlantı gelince sürer. + if (state.status === 'error') void retryUpdate() + else void checkVersionEndpoint() }) - window.addEventListener('online', () => void runUpdateCheck()) } /** @@ -548,12 +892,8 @@ function startUpdateWatchers() { function handleChunkLoadFailures() { window.addEventListener('vite:preloadError', (event) => { if (reloading) return - try { - if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return - sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1') - } catch { - return - } + if (readStorage(CHUNK_RELOAD_GUARD_KEY, true)) return + writeStorage(CHUNK_RELOAD_GUARD_KEY, '1', true) event.preventDefault() console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.') reloading = true @@ -561,22 +901,9 @@ function handleChunkLoadFailures() { }) // Sayfa sorunsuz açıldıysa koruma bayrağını temizle. - window.setTimeout(() => { - try { - sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY) - } catch { - /* yoksay */ - } - }, 10_000) + window.setTimeout(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined, true), 10_000) } -/* ------------------------------------------------------------------ */ -/* Genel API */ -/* ------------------------------------------------------------------ */ - -/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */ -const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true' - /** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */ async function unregisterAll() { try { @@ -587,20 +914,47 @@ async function unregisterAll() { } } +/* ------------------------------------------------------------------ */ +/* Genel API */ +/* ------------------------------------------------------------------ */ + export const registerServiceWorker = () => { - if (started || !('serviceWorker' in navigator)) return + // Dev'de HMR modülü yeniden çalıştırdığında modül seviyesindeki bayrak + // sıfırlanıyor ve zamanlayıcı/dinleyiciler üst üste biniyordu. + const scope = window as unknown as { __sozsoftUpdateEngineStarted?: boolean } + if (started || scope.__sozsoftUpdateEngineStarted) return started = true + scope.__sozsoftUpdateEngineStarted = true + + // Sürüm okuma ve deploy kontrolü service worker'dan bağımsızdır; dev'de de + // (SW kapalıyken) yeni deploy algılanır. + markRunningBuildSeen(RUNNING_VERSION) + + // Açılış performansı: sürüm isteği ve SW kaydı ilk boyamayı beklesin. + // Açılışta `force` verilmez; bildirim kancasıyla aynı istek paylaşılır. + const boot = () => { + void checkVersionEndpoint() + startWatchers() + } + const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void }) + .requestIdleCallback + if (idle) idle.call(window, boot, { timeout: 3_000 }) + else window.setTimeout(boot, 1_500) + + if (!('serviceWorker' in navigator)) return handleChunkLoadFailures() if (!isServiceWorkerEnabled) { // Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i - // (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her - // güncelleme kontrolü "unsupported MIME type" hatası verir. + // artık üretilmediği için sunucu index.html döner ve her güncelleme + // kontrolü "unsupported MIME type" hatası verir. initialization = unregisterAll() return } + navigator.serviceWorker.addEventListener('message', handleWorkerMessage) + initialization = new Promise((resolve) => { // Kayıt callback'i hiç çalışmazsa bile promise'i serbest bırak; aksi halde // `checkForAppUpdate()` "Kontrol ediliyor..." durumunda takılı kalır. @@ -616,15 +970,11 @@ export const registerServiceWorker = () => { if (reg) { registration = reg watchRegistration(reg) - startUpdateWatchers() // Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa // doğrudan devreye al. - if (reg.waiting && navigator.serviceWorker.controller) { - activateWaitingWorker() - } else { - void runUpdateCheck(true) - } + if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() + else scheduleWarmup(reg) } settle() }, @@ -635,7 +985,6 @@ export const registerServiceWorker = () => { onRegisterError(error) { activating = false clearActivationTimers() - teardownUpdateUi() console.error('Service worker kaydı başarısız.', error) settle() }, @@ -643,13 +992,17 @@ export const registerServiceWorker = () => { }) } +/** Kullanıcının menüden tetiklediği manuel kontrol. */ export const checkForAppUpdate = async (): Promise => { + setState({ status: state.status === 'idle' ? 'checking' : state.status }) + + const newDeploy = await checkVersionEndpoint(true) + if (newDeploy) return 'updating' + if (!('serviceWorker' in navigator)) return 'unsupported' if (!isServiceWorkerEnabled) return 'disabled' await initialization - // `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman - // resolve etmez; getRegistration() kayıt yoksa undefined döner. const reg = registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined)) if (!reg) return 'unsupported' @@ -684,7 +1037,10 @@ export const checkForAppUpdate = async (): Promise => { // Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek. if (staleRegistrationCleared) return 'up-to-date' - if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date' + if (!updateFound && !reg.installing && !reg.waiting) { + if (state.status === 'checking') setState({ status: 'idle' }) + return 'up-to-date' + } if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() // `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür. diff --git a/ui/src/views/version/useAppVersionNotice.tsx b/ui/src/views/version/useAppVersionNotice.tsx deleted file mode 100644 index fd41620c..00000000 --- a/ui/src/views/version/useAppVersionNotice.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect, useRef } from 'react' -import { useNavigate } from 'react-router-dom' -import { ROUTES_ENUM } from '@/routes/route.constant' -import { useStoreActions } from '@/store' -import { useLocalization } from '@/utils/hooks/useLocalization' -import { consumeAppUpdatedVersion } from './swRegistration' - -/** - * Açılışta çalışan tek sürüm kaynağı: - * - `version.json`'daki güncel sürümü store'a yazar (footer/changelog bunu okur). - * - Service worker güncellemesi sonrası yenilenen sayfada "güncellendi" - * bildirimini gösterip changelog'a yönlendirme sunar. - * - * Daha önce bu işi Login ekranı yapıyordu; store'daki başlangıç sürümü - * `undefined` olduğu için her girişte changelog'a zorla yönlendiriyordu. - */ -export const useAppVersionNotice = () => { - const navigate = useNavigate() - const { translate } = useLocalization() - const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion) - const handled = useRef(false) - - useEffect(() => { - if (handled.current) return - handled.current = true - - const label = (key: string, fallback: string) => { - const value = translate(key) - return !value || value === key || value.includes('::') ? fallback : value - } - - const updatedVersion = consumeAppUpdatedVersion() - - const controller = new AbortController() - fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal }) - .then((response) => (response.ok ? response.json() : undefined)) - .then(async (data?: { releases?: { version: string }[] }) => { - const latest = data?.releases?.[0]?.version - if (latest) setUiVersion(latest) - - if (!updatedVersion) return - - // toast/Notification (ve bağımlısı framer-motion) yalnızca güncelleme - // sonrası bu tek seferlik bildirimde gerekiyor; entry chunk'ta taşınmaz. - const [{ default: toast }, { default: Notification }] = await Promise.all([ - import('@/components/ui/toast'), - import('@/components/ui/Notification'), - ]) - - toast.push( - - {label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} —{' '} - - , - { placement: 'bottom-end' }, - ) - }) - .catch(() => { - /* sürüm bilgisi okunamadıysa sessiz geç */ - }) - - return () => controller.abort() - }, []) -} - -export default useAppVersionNotice diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 837db8e4..6294df57 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,22 +1,32 @@ import { defineConfig, loadEnv } from 'vite' +import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' +import fs from 'fs' import path from 'path' import { VitePWA } from 'vite-plugin-pwa' /** * Precache stratejisi * ------------------------------------------------------------------ - * `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar - * ~20 MB). Bunların tamamını precache etmek her deploy'da service worker'ın - * on-larca MB indirmesine, mobil/yavaş bağlantıda kurulumun timeout'a düşüp - * worker'ın `redundant` olmasına ve güncellemenin hiç tamamlanmamasına yol - * açıyordu. + * İki kademe vardır: * - * Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS, - * ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime - * cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için - * CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate - * kullanılır. + * 1. KABUK (workbox precache manifest'i, aşağıdaki `PRECACHE_GLOBS`) + * index.html + entry JS/CSS + her açılışta yüklenen vendor chunk'ları. + * Kurulumda ZORUNLUDUR; biri inmezse service worker kurulumu başarısız olur + * (yarım sürümle aktive olmak beyaz ekran demek). + * + * 2. BİLEŞENLER (`warmup.json`, bu dosyadaki plugin üretir) + * Tüm lazy chunk'lar ve CSS'leri. Güncelleme penceresi açıkken aynı ilerleme + * çubuğunda arka planda indirilir; böylece kullanıcı deploy'dan sonra ilk kez + * bir ekrana girdiğinde chunk beklemez. Best-effort'tur: inemeyen dosya + * kurulumu düşürmez, süre bütçesi aşılırsa kalanlar runtime cache'e kalır. + * + * `dist/css` altındaki 48 DevExtreme teması (~33 MB) bilerek dışarıdadır; + * kullanıcı başına yalnızca bir tema gerekir, runtime cache yeterlidir. + * + * Service worker `src/sw.ts` içinde elle yazıldı (strategies: injectManifest); + * çünkü güncelleme penceresinde indirilen her dosyanın adı/boyutu ve toplam + * yüzdesi gösterilebilsin diye indirme progress yayınlıyor. */ const PRECACHE_GLOBS = [ 'index.html', @@ -24,17 +34,75 @@ const PRECACHE_GLOBS = [ // otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir. 'favicon.ico', 'assets/js/entry-*.js', + 'assets/js/vendor-react-*.js', + 'assets/js/vendor-router-*.js', // Sadece entry CSS'i. Rapor tasarımcısı/görüntüleyici CSS'leri (~580 KB) // lazy yüklendiği için runtime cache'e bırakılır. 'assets/css/index-*.css', ] -// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için -// runtime cache'e alınacak hash'li uygulama varlıkları. -const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/ +/** Arka planda ısıtılacak bileşenler: uygulamanın tüm JS/CSS chunk'ları. */ +const WARMUP_FILE = /^assets\/(?:js|css)\/.+\.(?:js|css)$/ + +/** + * Build çıktısındaki chunk adlarını ve (sıkıştırılmamış) boyutlarını + * `dist/warmup.json` olarak yazar. Boyutun derleme anında bilinmesi, service + * worker'ın ilerleme yüzdesini ilk saniyeden itibaren doğru göstermesini sağlar + * (Content-Length gzip'li boyut olduğu için yüzde zıplıyordu). + */ +function warmupManifest(): Plugin { + return { + name: 'sozsoft-warmup-manifest', + apply: 'build', + enforce: 'post', + generateBundle(_options, bundle) { + const files = Object.values(bundle) + .filter((output) => WARMUP_FILE.test(output.fileName)) + .map((output) => { + const source = output.type === 'chunk' ? output.code : output.source + return { + url: `/${output.fileName}`, + size: + typeof source === 'string' + ? new TextEncoder().encode(source).byteLength + : source.byteLength, + } + }) + + // vite-plugin-pwa service worker'ı ayrı bir build ile üretir; o build'de + // eşleşen dosya olmaz. Boş liste yazmak gerçek manifest'i ezerdi. + if (files.length === 0) return + + this.emitFile({ + type: 'asset', + fileName: 'warmup.json', + source: JSON.stringify({ files }), + }) + }, + } +} + +/** + * `scripts/generate-version.js` build'den önce çalışır. Buradaki değerleri + * bundle'a gömüyoruz ki uygulama, çalışan kodun build kimliğini sunucudaki + * `version.json` ile birebir karşılaştırıp yeni deploy'u kesin olarak anlasın. + */ +function readBuildInfo() { + try { + const file = path.join(import.meta.dirname, 'public', 'version.json') + const data = JSON.parse(fs.readFileSync(file, 'utf8')) + return { + buildId: String(data.buildId ?? data.commit ?? 'dev'), + version: String(data.version ?? data.releases?.[0]?.version ?? '0.0.0'), + } + } catch { + return { buildId: 'dev', version: '0.0.0' } + } +} export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const buildInfo = readBuildInfo() const usePolling = env.VITE_USE_POLLING === 'true' // Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR // sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor. @@ -46,86 +114,29 @@ export default defineConfig(({ mode }) => { return { plugins: [ react(), + warmupManifest(), VitePWA({ // Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak // yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz. registerType: 'prompt', // Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir. injectRegister: false, + // Precache indirmesinin ilerlemesini kullanıcıya gösterebilmek için + // service worker elle yazıldı. + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', devOptions: { enabled: enableDevSw, type: 'module', + navigateFallback: 'index.html', }, - workbox: { + injectManifest: { globDirectory: 'dist', globPatterns: PRECACHE_GLOBS, - // Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB). maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, - - // Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING - // gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır. - clientsClaim: true, - skipWaiting: false, - cleanupOutdatedCaches: true, - - navigateFallback: '/index.html', - navigateFallbackAllowlist: [/^(?!\/__).*/], - navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/], - - runtimeCaching: [ - { - // Sürüm/changelog bilgisi asla eski kalmamalı. - urlPattern: /\/version\.json$/, - handler: 'NetworkFirst', - options: { - cacheName: 'app-version-v2', - networkTimeoutSeconds: 3, - expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - // Hash'li build çıktıları immutable'dır. - urlPattern: HASHED_ASSET_PATTERN, - handler: 'CacheFirst', - options: { - cacheName: 'app-assets-v3', - expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - // public/css altındaki DevExtreme temaları hash'siz; içerik - // sürümle değişebildiği için arka planda tazelenir. - urlPattern: /\/css\/[\w.-]+\.css$/, - handler: 'StaleWhileRevalidate', - options: { - cacheName: 'dx-themes-v1', - expiration: { maxEntries: 12, maxAgeSeconds: 30 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - urlPattern: /\.(?:woff2?|ttf|otf|eot)$/, - handler: 'CacheFirst', - options: { - cacheName: 'fonts-v1', - expiration: { maxEntries: 40, maxAgeSeconds: 180 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - urlPattern: /\.(?:png|jpe?g|svg|gif|webp|ico)$/, - handler: 'CacheFirst', - options: { - cacheName: 'images-v2', - expiration: { maxEntries: 150, maxAgeSeconds: 30 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - ], }, manifest: { @@ -133,7 +144,11 @@ export default defineConfig(({ mode }) => { short_name: 'Sözsoft Platform', theme_color: '#FF99C8', background_color: '#f0e7db', - display: 'browser', + // Tarayıcının "uygulamayı yükle" akışı (beforeinstallprompt) yalnızca + // standalone/minimal-ui görüntü modlarında tetiklenir. + display: 'standalone', + start_url: '/', + scope: '/', icons: [ { src: '/img/logo/logo-400.png', @@ -277,6 +292,10 @@ export default defineConfig(({ mode }) => { define: { 'process.env': {}, + // Çalışan bundle'ın kimliği. İstemci bunu sunucudaki version.json ile + // karşılaştırarak yeni deploy'u kesin olarak (tag'e ihtiyaç duymadan) anlar. + __APP_BUILD_ID__: JSON.stringify(buildInfo.buildId), + __APP_VERSION__: JSON.stringify(buildInfo.version), }, } })