Yeni versiyon güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-12 22:08:14 +03:00
parent f292b6b6a5
commit 534dcce6da
19 changed files with 2288 additions and 777 deletions

View file

@ -684,14 +684,17 @@ konfigürasyonu ile üretilir.
### 18.4. PWA ve sürüm yönetimi ### 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 - Kurulum iki kademelidir: **kabuk** (index.html + entry JS/CSS + eager vendor chunk'ları)
temaları, lazy chunk'lar, font ve görseller runtime cache ile saklanır. Sebebi: workbox precache manifest'inden gelir ve zorunludur; **bileşenler** (tüm lazy chunk'lar,
`dist` klasörü ~55 MB olduğundan tam precache, yavaş bağlantılarda service worker kurulumunun build'de üretilen `dist/warmup.json`) güncelleme penceresinde aynı ilerleme çubuğunda arka
timeout'a düşmesine yol açıyordu. planda indirilir. İkinci kademe best-effort'tur (inemeyen dosya kurulumu düşürmez, süre
- `registerType: 'prompt'` — yeni sürüm indirilir, kullanıcı onayıyla devreye alınır bütçesi aşılırsa kalanlar runtime cache'e kalır); böylece deploy sonrası ilk gezinmede chunk
(`views/version/swRegistration.ts`, `useAppVersionNotice.tsx`). 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` - `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. - `scripts/generate-version.js` build sırasında sürüm bilgisini üretir.
--- ---

View file

@ -114,6 +114,12 @@
} }
], ],
"LanguageTexts": [ "LanguageTexts": [
{
"resourceName": "Platform",
"key": "App.InstallApp",
"en": "Install app",
"tr": "Uygulamayı yükle"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Product.ListComponent", "key": "App.Product.ListComponent",

View file

@ -4,12 +4,17 @@ echo "Env değeri: ${ENV}"
cd ~/sozsoft-platform cd ~/sozsoft-platform
git checkout main 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 cd ~/sozsoft-platform/ui
# Git tag'den son versiyonu al (örn: v1.0.11 -> 1.0.11), yoksa package.json'daki versiyon # Sürüm, HEAD'den erişilebilen son tag'dir (başka daldaki tag'ler sayılmaz);
VERSION=$(git tag --sort=-creatordate | head -n1 | sed 's/^v//' || jq -r .version package.json) # 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}" echo "Build versiyonu: ${VERSION}"
# Deploy öncesi git üzerindeki tüm versiyon bilgilerini güncelle # Deploy öncesi git üzerindeki tüm versiyon bilgilerini güncelle

View file

@ -7,7 +7,7 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "node scripts/generate-version.js && vite build", "build": "node scripts/generate-version.js && vite build",
"build:production": "vite build", "build:production": "node scripts/generate-version.js && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",

View file

@ -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": [ "releases": [
{ {
"version": "1.1.12", "version": "1.2.1",
"buildDate": "2026-08-12",
"commit": "5cfd0105bc7100471edd21f8c1d215fdd6ab4d40",
"changeLog": [
"- Yeni versiyon güncellemesi"
]
},
{
"version": "1.1.11",
"buildDate": "2026-08-11", "buildDate": "2026-08-11",
"commit": "783c1fe1d35f7cb8f23fb63fad26123cbe54aafc", "commit": "783c1fe1d35f7cb8f23fb63fad26123cbe54aafc",
"changeLog": [ "changeLog": [
"- Uygulamanın genel optimizasyonu" "Claude.ai güncellemesi"
] ]
}, },
{ {
@ -22,10 +19,10 @@
"buildDate": "2026-07-31", "buildDate": "2026-07-31",
"commit": "2eef3ead35848341350659828a14dc4a463e57af", "commit": "2eef3ead35848341350659828a14dc4a463e57af",
"changeLog": [ "changeLog": [
"- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.", "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.", "Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.",
"- Scheduler ve TodoBard komponentler geliştirildi.", "Scheduler ve TodoBard komponentler geliştirildi.",
"- Lookup editorlerin Static değerlere dil desteği eklendi." "Lookup editorlerin Static değerlere dil desteği eklendi."
] ]
}, },
{ {
@ -33,21 +30,21 @@
"buildDate": "2026-07-29", "buildDate": "2026-07-29",
"commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e", "commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e",
"changeLog": [ "changeLog": [
"- Wizard yetenekleri artırıldı. Menu Order eklendi.", "Wizard yetenekleri artırıldı. Menu Order eklendi.",
"- GanttView sample düzenlemeleri", "GanttView sample düzenlemeleri",
"- Schedulerview sample düzenlemeleri", "Schedulerview sample düzenlemeleri",
"- CryptographyXml version 10.0.10" "CryptographyXml version 10.0.10"
] ]
}, },
{ {
"version": "1.1.08", "version": "1.1.08",
"buildDate": "2026-07-16", "buildDate": "2026-07-16",
"commit": "e5afb66d89cefc48beff4bbaa00c140bdc58fadc", "commit": "24e92fcb82eef0ee26ca5d65e08c5404f132a09e",
"changeLog": [ "changeLog": [
"- CardView komponenti geliştirildi.", "CardView komponenti geliştirildi.",
"- Todo / Kanban komponenti geliştirildi.", "Todo / Kanban komponenti geliştirildi.",
"- Licence information", "Licence information",
"- Check New Versiyon komponenti." "Check New Versiyon komponenti."
] ]
}, },
{ {
@ -55,11 +52,11 @@
"buildDate": "2026-07-13", "buildDate": "2026-07-13",
"commit": "50cb3eaf979eb5027172a92a4e941ed64aa3671e", "commit": "50cb3eaf979eb5027172a92a4e941ed64aa3671e",
"changeLog": [ "changeLog": [
"- localStorage tüm bilgiler easy-peasy içerisinde birleştirildi.", "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.", "Codex 5.6 sol optimizasyon yapıldı. Bir çok komponent Lazy load ile yüklendi.",
"- Workflow komponentinden birden fazla kişi onay verebilir.", "Workflow komponentinden birden fazla kişi onay verebilir.",
"- Posta Kodu eklendi.", "Posta Kodu eklendi.",
"- Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı" "Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı"
] ]
}, },
{ {
@ -67,9 +64,9 @@
"buildDate": "2026-07-06", "buildDate": "2026-07-06",
"commit": "bab639ce6343b451b81d6688e4f7af666f0233da", "commit": "bab639ce6343b451b81d6688e4f7af666f0233da",
"changeLog": [ "changeLog": [
"- Çekirdek kod için optimizasyon", "Çekirdek kod için optimizasyon",
"- List Reportlara Gruplu ve Alttoplam eklendi.", "List Reportlara Gruplu ve Alttoplam eklendi.",
"- Paketlerdeki deprecated uyarları kaldırıldı" "Paketlerdeki deprecated uyarları kaldırıldı"
] ]
}, },
{ {
@ -77,12 +74,12 @@
"buildDate": "2026-06-27", "buildDate": "2026-06-27",
"commit": "352cf047b0af1b8aad53b632a41161efcc6cecdf", "commit": "352cf047b0af1b8aad53b632a41161efcc6cecdf",
"changeLog": [ "changeLog": [
"- Ai için Ollama kurulumu yapıldı. Ayrıca Gemma LLM ile çalıştırı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ı.", "DynamicReport eklendi. ReportDesigner ile çalışan bir rapor tasarlandı.",
"- DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.", "DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.",
"- DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.", "DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.",
"- DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.", "DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.",
"- Demo sayfası menüden ayrıldı." "Demo sayfası menüden ayrıldı."
] ]
}, },
{ {
@ -90,22 +87,22 @@
"buildDate": "2026-06-04", "buildDate": "2026-06-04",
"commit": "20e7fae481ce69e9a678508ce03b5ed7831aea9f", "commit": "20e7fae481ce69e9a678508ce03b5ed7831aea9f",
"changeLog": [ "changeLog": [
"- Settingde yapılan ayarlar Auth komponentlerine uygulandı.", "Settingde yapılan ayarlar Auth komponentlerine uygulandı.",
"- Public home ve diğer sayfaların tasarım değişikliği yapıldı.", "Public home ve diğer sayfaların tasarım değişikliği yapıldı.",
"- Route Type Dinamik ve Normal olarak ayrıldı.", "Route Type Dinamik ve Normal olarak ayrıldı.",
"- Form Devexpress DefaultValue özelliği eklendi.", "Form Devexpress DefaultValue özelliği eklendi.",
"- Devexpress DarkModa uygun şekilde güncellendi.", "Devexpress DarkModa uygun şekilde güncellendi.",
"- Grid, Tree ve FormDevexpress setReadonly özelliği eklendi." "Grid, Tree ve FormDevexpress setReadonly özelliği eklendi."
] ]
}, },
{ {
"version": "1.1.03", "version": "1.1.03",
"buildDate": "2026-05-30", "buildDate": "2026-05-30",
"commit": "f9a65a3c8b2920ea40abcba501f0e8673f51f09b", "commit": "96f7091d46c248ba3c42849fe5d870db0ab96982",
"changeLog": [ "changeLog": [
"- User Detail komponentinin içerisinde Avatar ekleme", "User Detail komponentinin içerisinde Avatar ekleme",
"- EditForm un içerisinde EditorOptions dinamik oluşturulması", "EditForm un içerisinde EditorOptions dinamik oluşturulması",
"- Editform un içerisinde EditorScript dinamik oluşturulması" "Editform un içerisinde EditorScript dinamik oluşturulması"
] ]
}, },
{ {
@ -113,11 +110,11 @@
"buildDate": "2026-05-27", "buildDate": "2026-05-27",
"commit": "84b9f6510787bd82f3797728fd675f755b4caa2d", "commit": "84b9f6510787bd82f3797728fd675f755b4caa2d",
"changeLog": [ "changeLog": [
"- .NET 10 yükseltildi.", ".NET 10 yükseltildi.",
"- Abp Framework 10 yükseltildi.", "Abp Framework 10 yükseltildi.",
"- Sql Query Manager problemleri giderildi.", "Sql Query Manager problemleri giderildi.",
"- AuditLog problemleri giderildi.", "AuditLog problemleri giderildi.",
"- Tenan yapısını uygunluğu için düzenlemeler yapıldı." "Tenan yapısını uygunluğu için düzenlemeler yapıldı."
] ]
}, },
{ {
@ -125,10 +122,10 @@
"buildDate": "2026-05-24", "buildDate": "2026-05-24",
"commit": "6262baa6f12d695a25d83304af985092715d439a", "commit": "6262baa6f12d695a25d83304af985092715d439a",
"changeLog": [ "changeLog": [
"- Workflow tanımlaması yapılabilir.", "Workflow tanımlaması yapılabilir.",
"- Dark mod için uygulama güncellemesi", "Dark mod için uygulama güncellemesi",
"- Form Field kısmında düzenleme", "Form Field kısmında düzenleme",
"- Hangfire Recurring Job düzenlemesi." "Hangfire Recurring Job düzenlemesi."
] ]
}, },
{ {
@ -136,19 +133,19 @@
"buildDate": "2026-05-11", "buildDate": "2026-05-11",
"commit": "414006204e324018be597a598d3dd102868c5cca", "commit": "414006204e324018be597a598d3dd102868c5cca",
"changeLog": [ "changeLog": [
"- Backup dosyalarını son 5 günün kalması", "Backup dosyalarını son 5 günün kalması",
"- Grid için Fit Columns özelliği eklendi.", "Grid için Fit Columns özelliği eklendi.",
"- Notification Desktop, UiActivity, UiToast özelliği eklendi", "Notification Desktop, UiActivity, UiToast özelliği eklendi",
"- Versiyon güncellemeleri için \"System Updating\" mesajı" "Versiyon güncellemeleri için \"System Updating\" mesajı"
] ]
}, },
{ {
"version": "1.0.9", "version": "1.0.9",
"buildDate": "2026-05-09", "buildDate": "2026-05-09",
"commit": "13e35b961f4a0cb7288ee04e179fa6f61ee996ab", "commit": "e9d8f5ebac35fc7b21f08139dfe175ed548c14b4",
"changeLog": [ "changeLog": [
"- Video Rooms düzenlemesi", "Video Rooms düzenlemesi",
"- Genel Liste düzenlemeleri" "Genel Liste düzenlemeleri"
] ]
}, },
{ {
@ -156,10 +153,10 @@
"buildDate": "2026-05-07", "buildDate": "2026-05-07",
"commit": "6fa266f23ed4f67384b7bef0e4030420cb788af5", "commit": "6fa266f23ed4f67384b7bef0e4030420cb788af5",
"changeLog": [ "changeLog": [
"- Intranet ve Sosyal Duvar", "Intranet ve Sosyal Duvar",
"- Listelerde ImageUploadandViewer", "Listelerde ImageUploadandViewer",
"- Survey Widget", "Survey Widget",
"- Event, Category ve Type" "Event, Category ve Type"
] ]
}, },
{ {
@ -167,10 +164,10 @@
"buildDate": "2026-05-04", "buildDate": "2026-05-04",
"commit": "4444fce93b4f62ad6698099fefe7da1614d2859c", "commit": "4444fce93b4f62ad6698099fefe7da1614d2859c",
"changeLog": [ "changeLog": [
"- Departman tanımlama", "Departman tanımlama",
"- Job Position tanımlama", "Job Position tanımlama",
"- Wizard ve SQl Data güncellemeleri", "Wizard ve SQl Data güncellemeleri",
"- Organizasyon Şemanı eklendi" "Organizasyon Şemanı eklendi"
] ]
}, },
{ {
@ -178,10 +175,10 @@
"buildDate": "2026-05-03", "buildDate": "2026-05-03",
"commit": "b2dfb04879a93709b04f8f0fc3bd4f5af1f7c01b", "commit": "b2dfb04879a93709b04f8f0fc3bd4f5af1f7c01b",
"changeLog": [ "changeLog": [
"- SalesOrder ve SalesOrderItem listFormu tanımlandı.", "SalesOrder ve SalesOrderItem listFormu tanımlandı.",
"- Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.", "Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.",
"- Wizard File Manager komponenti oluşturuldu.", "Wizard File Manager komponenti oluşturuldu.",
"- Wizard komponentini Edit özelliği eklendi." "Wizard komponentini Edit özelliği eklendi."
] ]
}, },
{ {
@ -189,11 +186,11 @@
"buildDate": "2026-04-28", "buildDate": "2026-04-28",
"commit": "b9cc68ff41d675fefadab79f5ea2c5e7f376708a", "commit": "b9cc68ff41d675fefadab79f5ea2c5e7f376708a",
"changeLog": [ "changeLog": [
"- AllowAdding özelliği eklendi.", "AllowAdding özelliği eklendi.",
"- Kulllanıcı bazında Workhour özelliği eklendi.", "Kulllanıcı bazında Workhour özelliği eklendi.",
"- ConcurrentUser sınırlaması getirildi.", "ConcurrentUser sınırlaması getirildi.",
"- Uygulamadan kullanıcıyı kickleme özelliği getirildi.", "Uygulamadan kullanıcıyı kickleme özelliği getirildi.",
"- Uygulama açıkken DbMigrator çağrısı yapıldı." "Uygulama açıkken DbMigrator çağrısı yapıldı."
] ]
}, },
{ {
@ -201,9 +198,9 @@
"buildDate": "2026-03-30", "buildDate": "2026-03-30",
"commit": "e9ce256c0706de408e08a462bee9e4a72653f2c7", "commit": "e9ce256c0706de408e08a462bee9e4a72653f2c7",
"changeLog": [ "changeLog": [
"- Role Yetkilerini kopyalama", "Role Yetkilerini kopyalama",
"- Place Holder güncellemesi", "Place Holder güncellemesi",
"- Kayıt kopyalama özelliği" "Kayıt kopyalama özelliği"
] ]
}, },
{ {
@ -234,4 +231,4 @@
] ]
} }
] ]
} }

View file

@ -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 fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process' import { execSync } from 'child_process'
function safeExec(cmd) { function safeExec(cmd) {
try { try {
return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true }) return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true }).toString().trim()
.toString()
.trim()
} catch { } catch {
return null return null
} }
} }
let releases = [] const scriptDir = path.dirname(fileURLToPath(import.meta.url))
let commit = null const uiRoot = path.resolve(scriptDir, '..')
const outputFile = path.join(uiRoot, 'public', 'version.json')
if (fs.existsSync('.git') || fs.existsSync('../.git')) { const pkg = JSON.parse(fs.readFileSync(path.join(uiRoot, 'package.json'), 'utf8'))
// Git varsa: taglerden oku const packageVersion = pkg.version || '0.0.0'
const rawTags = safeExec('git tag --list --sort=creatordate')
if (rawTags) { const buildTime = new Date().toISOString()
const tags = rawTags.split('\n').filter(Boolean) const buildDate = buildTime.slice(0, 10)
releases = tags.map((tag) => {
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 version = tag.replace(/^v/, '')
const date = safeExec(`git log -1 --format=%ad --date=short ${tag}`) const date = safeExec(`git log -1 --format=%ad --date=short ${tag}`)
const commitId = safeExec(`git rev-list -n 1 ${tag}`) const commitId = safeExec(`git rev-list -n 1 ${tag}`)
const messageRaw = safeExec(`git tag -l --format="%(contents)" ${tag}`) const changeLog = toChangeLog(safeExec(`git tag -l --format="%(contents)" ${tag}`))
const changeLog = messageRaw
? messageRaw
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
: ['No changelog for this tag']
return { return {
version, version,
buildDate: date, buildDate: date || buildDate,
commit: commitId, 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 = [ releases = [
{ {
version, version: packageVersion,
buildDate: new Date().toISOString().slice(0, 10), buildDate,
commit, commit,
changeLog: [ changeLog: ['Yerel geliştirme derlemesi', 'Git bilgisi bulunamadı, package.json sürümü kullanıldı'],
'Local development build',
'Git bilgisi mevcut değil, package.json versiyonu 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)) fs.mkdirSync(path.dirname(outputFile), { recursive: true })
//console.log('> version.json güncellendi:', versionInfo) fs.writeFileSync(outputFile, JSON.stringify(versionInfo, null, 2))
console.log(`> version.json: v${versionInfo.version} (${versionInfo.buildId})`)

View file

@ -1,35 +1,50 @@
#!/bin/bash #!/bin/bash
set -e 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..." echo "> version.json oluşturuluyor..."
# En yeni tag en üstte olsun if command -v node >/dev/null 2>&1; then
VERSIONS=$(git tag --sort=-creatordate) 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) 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 FIRST=true
for TAG in $VERSIONS; do for TAG in $VERSIONS; do
VER=$(echo $TAG | sed 's/^v//') VER=$(echo "$TAG" | sed 's/^v//')
DATE=$(git log -1 --format=%ad --date=short $TAG) DATE=$(git log -1 --format=%ad --date=short "$TAG")
COMMIT=$(git rev-list -n 1 $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 if [ "$FIRST" = true ]; then FIRST=false; else RELEASES+=","; fi
MESSAGE=$(git tag -l --format="%(contents)" $TAG | grep -v '^$' | jq -R . | jq -s .) RELEASES+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}"
if [ "$FIRST" = true ]; then
FIRST=false
else
OUTPUT+=","
fi
OUTPUT+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}"
done 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:" echo "> version.json: v${VERSION} (${BUILD_ID})"
#cat public/version.json

View file

@ -10,7 +10,7 @@ import { Helmet } from 'react-helmet'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { getSetupStatus } from '@/services/setup.service' import { getSetupStatus } from '@/services/setup.service'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { useAppVersionNotice } from '@/views/version/useAppVersionNotice' import { getRunningVersion } from '@/views/version/swRegistration'
let didInit = false let didInit = false
@ -73,7 +73,13 @@ const Theme = (props: CommonProps) => {
'en' 'en'
useDarkMode() useDarkMode()
useTabFocus() 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']> type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>

View file

@ -17,6 +17,7 @@ import {
FcVoicePresentation, FcVoicePresentation,
} from 'react-icons/fc' } from 'react-icons/fc'
import { useManualUpdateCheck } from '@/views/version/ChangeLog' import { useManualUpdateCheck } from '@/views/version/ChangeLog'
import InstallAppMenuItem from '@/views/version/InstallAppMenuItem'
type DropdownList = { type DropdownList = {
label: string label: string
@ -122,6 +123,8 @@ const UserDropdownBase = ({ className }: CommonProps) => {
) )
})} })}
<InstallAppMenuItem />
<Dropdown.Item <Dropdown.Item
eventKey="Check New Version" eventKey="Check New Version"
className="mb-1 px-0" className="mb-1 px-0"

716
ui/src/sw.ts Normal file
View 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 ı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ı ı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))
}
})

View file

@ -13,6 +13,8 @@ import { isLoginSuccess } from '../../proxy/account/models'
import { REDIRECT_URL_KEY } from '../../constants/app.constant' import { REDIRECT_URL_KEY } from '../../constants/app.constant'
import appConfig from '../../proxy/configs/app.config' import appConfig from '../../proxy/configs/app.config'
import { register } from '../../services/account.service' import { register } from '../../services/account.service'
import { hasPendingChangelog } from '@/views/version/swRegistration'
import { ROUTES_ENUM } from '@/routes/route.constant'
type Status = 'success' | 'failed' | 'error' type Status = 'success' | 'failed' | 'error'
@ -68,7 +70,12 @@ function useAuth() {
handleToken({ token, refreshToken, expiresIn }) handleToken({ token, refreshToken, expiresIn })
const redirectUrl = query.get(REDIRECT_URL_KEY) 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 { return {
status: 'success', status: 'success',

View 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 ı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

View file

@ -6,7 +6,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { FaRegCheckCircle, FaSyncAlt, FaTag } from 'react-icons/fa' import { FaRegCheckCircle, FaSyncAlt, FaTag } from 'react-icons/fa'
import { checkForAppUpdate } from './swRegistration' import { checkForAppUpdate, clearPendingChangelog, fetchVersionInfo } from './swRegistration'
type Release = { type Release = {
version: string version: string
@ -115,14 +115,12 @@ const Changelog = () => {
const { isCheckingUpdate, checkForUpdate } = useManualUpdateCheck() const { isCheckingUpdate, checkForUpdate } = useManualUpdateCheck()
useEffect(() => { useEffect(() => {
fetch('/version.json?ts=' + Date.now()) // Bekleyen changelog bayrağı burada tüketilir; kullanıcı değişiklikleri
.then((res) => res.json()) // gördüğü için sonraki girişlerde tekrar yönlendirilmez.
.then((data) => { clearPendingChangelog()
if (data?.releases) {
setReleases(data.releases) // 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[]) ?? []))
})
.catch(() => setReleases([]))
}, []) }, [])
return ( return (

View 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

View 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 }),
)
}

View 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

View file

@ -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'
/**
* ı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

View file

@ -1,22 +1,32 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import fs from 'fs'
import path from 'path' import path from 'path'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
/** /**
* Precache stratejisi * Precache stratejisi
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar * İki kademe vardır:
* ~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
* ıyordu.
* *
* Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS, * 1. KABUK (workbox precache manifest'i, aşağıdaki `PRECACHE_GLOBS`)
* ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime * index.html + entry JS/CSS + her ılışta yüklenen vendor chunk'ları.
* cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için * Kurulumda ZORUNLUDUR; biri inmezse service worker kurulumu başarısız olur
* CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate * (yarım sürümle aktive olmak beyaz ekran demek).
* kullanılır. *
* 2. BİLEŞENLER (`warmup.json`, bu dosyadaki plugin üretir)
* Tüm lazy chunk'lar ve CSS'leri. Güncelleme penceresi ı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 ı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 = [ const PRECACHE_GLOBS = [
'index.html', 'index.html',
@ -24,17 +34,75 @@ const PRECACHE_GLOBS = [
// otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir. // otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir.
'favicon.ico', 'favicon.ico',
'assets/js/entry-*.js', '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) // 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. // lazy yüklendiği için runtime cache'e bırakılır.
'assets/css/index-*.css', 'assets/css/index-*.css',
] ]
// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için /** Arka planda ısıtılacak bileşenler: uygulamanın tüm JS/CSS chunk'ları. */
// runtime cache'e alınacak hash'li uygulama varlıkları. const WARMUP_FILE = /^assets\/(?:js|css)\/.+\.(?:js|css)$/
const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/
/**
* 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 }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const buildInfo = readBuildInfo()
const usePolling = env.VITE_USE_POLLING === 'true' const usePolling = env.VITE_USE_POLLING === 'true'
// Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR // 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. // sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor.
@ -46,86 +114,29 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: [ plugins: [
react(), react(),
warmupManifest(),
VitePWA({ VitePWA({
// Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak // 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. // yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz.
registerType: 'prompt', registerType: 'prompt',
// Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir. // Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir.
injectRegister: false, 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: { devOptions: {
enabled: enableDevSw, enabled: enableDevSw,
type: 'module', type: 'module',
navigateFallback: 'index.html',
}, },
workbox: { injectManifest: {
globDirectory: 'dist', globDirectory: 'dist',
globPatterns: PRECACHE_GLOBS, globPatterns: PRECACHE_GLOBS,
// Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB). // Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB).
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, 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: { manifest: {
@ -133,7 +144,11 @@ export default defineConfig(({ mode }) => {
short_name: 'Sözsoft Platform', short_name: 'Sözsoft Platform',
theme_color: '#FF99C8', theme_color: '#FF99C8',
background_color: '#f0e7db', 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: [ icons: [
{ {
src: '/img/logo/logo-400.png', src: '/img/logo/logo-400.png',
@ -277,6 +292,10 @@ export default defineConfig(({ mode }) => {
define: { define: {
'process.env': {}, '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),
}, },
} }
}) })