Yeni versiyon güncellemesi
This commit is contained in:
parent
f292b6b6a5
commit
534dcce6da
19 changed files with 2288 additions and 777 deletions
17
README.md
17
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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@
|
|||
}
|
||||
],
|
||||
"LanguageTexts": [
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.InstallApp",
|
||||
"en": "Install app",
|
||||
"tr": "Uygulamayı yükle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Product.ListComponent",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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})`)
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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<ComponentProps<typeof ConfigProvider>['value']>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
)
|
||||
})}
|
||||
|
||||
<InstallAppMenuItem />
|
||||
|
||||
<Dropdown.Item
|
||||
eventKey="Check New Version"
|
||||
className="mb-1 px-0"
|
||||
|
|
|
|||
716
ui/src/sw.ts
Normal file
716
ui/src/sw.ts
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
/**
|
||||
* Sözsoft Platform service worker (injectManifest)
|
||||
* ------------------------------------------------------------------
|
||||
* Workbox runtime'ı yerine elle yazıldı; tek sebebi güncelleme sırasında
|
||||
* indirilen HER dosyanın adını, boyutunu ve toplam yüzdesini uygulamaya
|
||||
* yayınlayabilmek (güncelleme penceresindeki progress listesi).
|
||||
*
|
||||
* Kurulum iki kademelidir:
|
||||
* 1. KABUK (`__WB_MANIFEST`): index.html + entry JS/CSS + eager vendor
|
||||
* chunk'ları. Zorunludur; biri inmezse kurulum başarısız olur.
|
||||
* 2. BİLEŞENLER (`/warmup.json`): tüm lazy chunk'lar. Kullanıcı deploy sonrası
|
||||
* ekranlara girerken chunk beklemesin diye aynı ilerleme çubuğunda arka
|
||||
* planda indirilir. Best-effort: inemeyen dosya kurulumu düşürmez, süre
|
||||
* bütçesi (WARMUP_BUDGET_MS) dolarsa kalanlar runtime cache'e bırakılır.
|
||||
*
|
||||
* Boyut/performans notları:
|
||||
* - Tema CSS'i / görsel / font precache'e girmez, runtime cache'e alınır.
|
||||
* - Zaten cache'de olan dosya yeniden indirilmez (hash'li ad = aynı içerik).
|
||||
* - İndirme paralel havuzla yapılır; ilerleme mesajları 150 ms'de bir ve
|
||||
* yalnızca DEĞİŞEN dosyalar için gönderilir (500 dosyalık listeyi her tick'te
|
||||
* kopyalamak hem SW hem render tarafında pahalı).
|
||||
*/
|
||||
|
||||
export type PrecacheEntry = { url: string; revision?: string | null }
|
||||
|
||||
type SwEvent = Event & {
|
||||
waitUntil: (promise: Promise<unknown>) => void
|
||||
respondWith: (response: Response | Promise<Response>) => 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<void>
|
||||
clients: {
|
||||
claim: () => Promise<void>
|
||||
matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]>
|
||||
}
|
||||
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<string, string>()
|
||||
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<WarmupEntry[]> {
|
||||
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<WarmupEntry[]> | 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<string, unknown>) {
|
||||
// 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<string, unknown>) | 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<number>()
|
||||
const inFlight = new Set<number>()
|
||||
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))
|
||||
}
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
262
ui/src/views/version/AppUpdateDialog.tsx
Normal file
262
ui/src/views/version/AppUpdateDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<li className="py-1.5" style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 38px' }}>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate font-medium text-gray-700 dark:text-gray-200" title={file.url}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{file.status === 'done'
|
||||
? formatBytes(file.total || file.loaded)
|
||||
: `${formatBytes(file.loaded)} / ${file.total ? formatBytes(file.total) : '—'}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
className={`h-full rounded transition-[width] duration-200 ${color}`}
|
||||
style={{ width: `${file.status === 'done' ? 100 : percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
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<string, number> = { 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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex max-h-[88vh] w-full max-w-[640px] flex-col overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-gray-800">
|
||||
<div className="flex items-start gap-3 px-6 pt-6">
|
||||
{busy && (
|
||||
<span className="mt-1 h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500 dark:border-gray-600 dark:border-t-indigo-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{titleFor(state)}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{descriptionFor(state)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{upgrading ? 'Mevcut' : 'Sürüm'}: v{state.currentVersion}
|
||||
</span>
|
||||
{/* İlk kurulumda geçilecek bir sürüm yok; aynı numarayı iki kez göstermeyelim. */}
|
||||
{upgrading && (
|
||||
<>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="rounded-full bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||
Yeni: v{state.version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(busy || state.files.length > 0) && (
|
||||
<div className="px-6 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{doneCount}/{state.files.length} bileşen
|
||||
{state.reusedCount > 0 && ` · ${state.reusedCount} bileşen önbellekten`}
|
||||
{state.status === 'downloading' && state.secondsRemaining !== undefined && (
|
||||
<> · kalan ~{formatDuration(state.secondsRemaining)}</>
|
||||
)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatBytes(state.loadedBytes)}
|
||||
{state.totalBytes > 0 && ` / ${formatBytes(state.totalBytes)}`} · %{state.percent}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-[width] duration-200"
|
||||
style={{ width: `${state.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-y-auto px-6">
|
||||
{state.files.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
İndirilen bileşenler
|
||||
</p>
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{ordered.map((file) => (
|
||||
<FileRow key={file.url} file={file} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.changeLog.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
v{state.version} ile gelen yenilikler
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
{state.changeLog.map((line, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="mt-0.5 text-emerald-500">✓</span>
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">
|
||||
{state.dismissible
|
||||
? 'Pencereyi kapatsanız da indirme arka planda sürer.'
|
||||
: 'Güncelleme tamamlanana kadar bu pencere kapanmaz.'}
|
||||
</span>
|
||||
{state.dismissible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
onClick={dismissAppUpdateDialog}
|
||||
>
|
||||
{state.status === 'completed' ? 'Tamam' : 'Arka planda devam et'}
|
||||
</button>
|
||||
) : (
|
||||
(state.status === 'reload-required' || state.status === 'error') && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
onClick={applyAppUpdate}
|
||||
>
|
||||
{state.status === 'error' ? 'Şimdi tekrar dene' : 'Şimdi geç'}
|
||||
{countdownText(state)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppUpdateDialog
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
37
ui/src/views/version/InstallAppMenuItem.tsx
Normal file
37
ui/src/views/version/InstallAppMenuItem.tsx
Normal file
|
|
@ -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 (
|
||||
<Dropdown.Item
|
||||
eventKey="Install App"
|
||||
className="mb-1 px-0"
|
||||
onClick={() => void promptAppInstall()}
|
||||
>
|
||||
<span className="flex h-full w-full items-center gap-2 px-2">
|
||||
<span className="text-xl opacity-50">
|
||||
<FcDownload />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallAppMenuItem
|
||||
87
ui/src/views/version/installPrompt.ts
Normal file
87
ui/src/views/version/installPrompt.ts
Normal file
|
|
@ -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<void>
|
||||
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<InstallState>) {
|
||||
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 }),
|
||||
)
|
||||
}
|
||||
21
ui/src/views/version/mountAppUpdateDialog.tsx
Normal file
21
ui/src/views/version/mountAppUpdateDialog.tsx
Normal file
|
|
@ -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(<AppUpdateDialog />)
|
||||
}
|
||||
|
||||
export default mountAppUpdateDialog
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
<Notification
|
||||
closable
|
||||
duration={10000}
|
||||
title={label('::App.UpdateCompleted', 'Uygulama güncellendi')}
|
||||
type="success"
|
||||
>
|
||||
{label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} —{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => navigate(ROUTES_ENUM.protected.admin.changeLog)}
|
||||
>
|
||||
{label('::App.ChangeLog', 'Değişiklikleri gör')}
|
||||
</button>
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
/* sürüm bilgisi okunamadıysa sessiz geç */
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
}
|
||||
|
||||
export default useAppVersionNotice
|
||||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue