Ana içeriğe geç
Ahmet Faruk UzunkayaYaratıcı geliştirici
Tüm yazılar
DerinlemesineTarayıcıda işletim sistemleri · 1/4Mühendislik7 dk

Tarayıcıdaki Windows 11'imde açılışı bilerek yavaş tuttum

react-os-win'de açılışı bilerek yavaşlattım ve bütün süreleri tek bir dosyada topladım. Pencereleri 14 action'lık saf bir reducer yönetiyor, 19 uygulamanın her biri de kendi hata sınırına sarılmış lazy bir bileşen. z-index sayacını, snap düzenlerini, localStorage'a neyin yazıldığını ve testlerin neyi kapsadığını koda bakarak anlatıyorum.

  • React
  • TypeScript
  • Durum Yönetimi
  • useReducer
Tarayıcıdaki Windows 11'imde açılışı bilerek yavaş tuttum

react-os-win'i soğuk açarsan masaüstüne varman yaklaşık 14,7 saniye sürüyor. Önce bir MSI BIOS POST ekranı geliyor, sonra Windows 11 açılış ekranı, ardından kendiliğinden açılan bir kilit ekranı ve daktilo efektiyle yazılan bir parola. Bu süreyi bilerek böyle bıraktım; bootTimings.ts'teki yorum toplamı kalem kalem hesaplıyor. Arkada React 19, TypeScript 6 ve Vite 8 ile yazdığım bir uygulama var; React dışındaki tek runtime bağımlılığı da lucide-react. Durumu üç Context'te tutuyorum, kaydedilen her şey de localStorage'a gidiyor.

Açılış tek bir değerde

Açılışın hangi aşamada olduğunu tek bir değerde tutuyorum (src/context/SystemContext.tsx):

export type BootStage = "bios" | "loading" | "login" | "desktop";
// ...
  const restartSystem = () => {
    closeSystemPanels();
    setIsShutdown(false);
    setBootStageState("bios");
  };
 
  const lockSystem = () => {
    closeSystemPanels();
    setIsShutdown(false);
    setBootStageState("login");
  };

App.tsx'te bu değere bakıp MSIBios, LoadingScreen, LoginScreen ya da masaüstünü render eden bir switch var. Geçişleri yöneten merkezi bir fonksiyon yok: her ekran mount olunca kendi setTimeout'unu kuruyor ve sıradaki aşamayı setBootStage ile atıyor. Kilit ekranı için de ayrı bir aşama yok, o login içindeki yerel bir isLocked state'i. Kapatma da aşamalardan biri sayılmıyor. isShutdown ayrı bir boolean ve App.tsx onu switch'ten önce kontrol ettiği için her aşamanın önüne geçiyor. Kapat'a basınca 1400 ms boyunca siyah bir ekran görünüyor, sonra tarayıcı ahmetfuzunkaya.com'a gidiyor. Yeniden Başlat bios'a, Kilitle login'e dönüyor; kilitledikten sonra otomatik giriş baştan oynuyor. Aşama tek bir değer olduğu için kilit ekranıyla masaüstü hiçbir zaman aynı anda açık olamıyor. isShutdown ise bu garantinin dışında kalan ikinci bir bayrak.

Süreleri bileşenlere gömmedim, hepsi tek dosyada. src/components/Boot/bootTimings.ts'in başındaki yorum da ince ayarın bileşenlerde değil, burada yapılmasını istiyor:

/** Win11 boot splash (black + logo + dotted spinner). Target visibly >=6s. */
export const BOOT_SPLASH_MS = 6500; // was 4500 (prev 3000) - must feel slow
// ...
/** Lock screen -> password panel before typewriter starts. */
export const UNLOCK_DELAY_MS = 400; // was 280
 
/** Pause after unlock before first password character. */
export const TYPEWRITER_START_MS = 350; // was 180
// ...
export const TYPEWRITER_CHAR_MS = 170;

Yorumlar değerlerin geçmişini de tutuyor. Açılış ekranını önce 3000 ms'den 4500'e, sonra 6500'e çıkardım ve gerekçeyi de yanına yazdım: yavaş hissettirmeli. Kilit ekranından parola paneline geçiş 280'den 400 ms'ye, daktilonun başlamadan önceki beklemesi de 180'den 350 ms'ye uzadı. BIOS ekranı BIOS_POST_MS (4500 ms) dolunca loading'e geçiyor. Bu sırada DEL'e basarsan effect'in cleanup'ı POST zamanlayıcısını iptal ediyor ve tam ekran MSIBIOSSetup açılıyor; oradan F10 ile loading'e çıkabiliyorsun. Kilit ekranı tıklamanı ya da bir tuşa basmanı beklemiyor, 400 ms sonra kendiliğinden açılıyor. 13 karakterlik parola karakter başına 170 ms'de yazılıyor ve son karakterden 700 ms sonra oturum açılıyor; bu kısım yaklaşık 3,7 saniye sürüyor. Parola alanına elle yazmaya başlarsan otomasyon iptal oluyor. Açıkçası handleLogin parolayı hiç kontrol etmiyor. Ekrandaki kullanıcı adı da src/osUser.ts'teki OS_USER.displayName'den geliyor: Ahmet Faruk Uzunkaya.

Durum üç Context'e dağılmış

ContextMekanizmaTuttuğu durumlocalStorage
SystemContextayrı ayrı useStateaçılış aşaması, tema, duvar kâğıdı, ağ, ses ve parlaklık, beş panel, kurulu uygulamalar12 anahtar (win11_theme, win11_wallpaper...)
FSContexttek useState<VFSNode>sanal dosya sistemiwin11_vfs
WindowContextuseReducer(windowReducer, undefined, loadInitialState)açık pencereler, aktif pencerewin11_windows, win11_active_window_id

Görev çubuğuna sabitlenmiş uygulamaları ve Chrome yer imlerini Context dışındaki modüllerde saklıyorum; toplamda 18 win11_* anahtarı var. SystemContext'te reducer kullanmadım, "aynı anda iki panel açık" durumunu setter seviyesinde engelliyorum:

  // Opening one panel closes the others.
  const panelSetters = useMemo(() => {
    const exclusive = (open: (val: boolean) => void) => (val: boolean) => {
      if (val) closeSystemPanels();
      open(val);
    };
    return {
      setStartOpen: exclusive(setStartOpen),
      setQuickSettingsOpen: exclusive(setQuickSettingsOpen),
      // ...
    };
  }, [closeSystemPanels]);

Start, Quick Settings, Takvim, Widget'lar ve Görev Görünümü panellerinden birini açan her setter önce hepsini kapatıyor. Sayısal ayarları okuyan readStoredNumber'ı da küçük bir tuzak yüzünden yazdım: Number(null) sıfır döndürüyor. Kayıt yokken değeri doğrudan çevirseydim ses ve parlaklık sıfırdan başlardı; fonksiyon bu durumda varsayılan değere düşüyor.

Pencereler saf bir reducer'da

Her pencere bir AppWindow. İçinde konum ve boyut, isMinimized, isMaximized, zIndex, uygulamaya giden params, snap'lenmişse snapLayout ve büyütmeden ya da snap'ten önceki boyutu saklayan restoreBounds var. State'te bunlara ek olarak activeWindowId ve desktopSnapshot duruyor. Reducer 14 action tanıyor: open, close, minimize, maximize, restore, focus, move, resize, snap, minimize-all, toggle-desktop, cycle, reconcile, update-meta.

Reducer'ı saf tutmak için pencere id'sini (${appId}:${crypto.randomUUID()}) ve ekrandan ölçülen çalışma alanını reducer'ın içinde üretmiyorum; bunları openApp action'a koyuyor. Bu sayede testler de aynı reducer'ı sabit bir AREA = { width: 1200, height: 752 } ve elle yazılmış id'lerle çağırabiliyor.

Pencerelerin üst üste dizilme sırası için bir sayaç kullanıyorum (src/context/WindowContext.tsx):

const nextZIndex = (windows: AppWindow[]) =>
  Math.max(9, ...windows.map((item) => item.zIndex)) + 1;
// ...
    case "focus": {
      const target = state.windows.find((item) => item.id === action.id);
      if (!target) return state;
      const topZ = Math.max(9, ...state.windows.map((item) => item.zIndex));
      const needsRaise = target.isMinimized || target.zIndex < topZ;
      // Already focused & on top: keep same state so inputs (Terminal) keep focus.
      if (
        !needsRaise &&
        state.activeWindowId === action.id &&
        !target.isMinimized &&
        state.desktopSnapshot === null
      ) {
        return state;
      }

Öne gelen pencere en büyük değerin bir fazlasını alıyor, windows dizisi ise açılış sırasında kalıyor. Zaten en üstte olan pencereye tıklarsan reducer aynı state referansını döndürüyor. Böylece React yeniden render etmiyor ve Terminal'deki input odağını kaybetmiyor. Sayaç durmadan büyümesin diye de sayfa yüklenirken z-index'leri, sırayı bozmadan 10'dan başlayarak yeniden numaralıyorum.

Diğer action'ların da kendi küçük kuralları var. maximize bir toggle: pencere büyütülmüşse restoreBounds'a dönüyor, değilse o anki sınırlar oraya kaydediliyor ve pencere büyüyor. minimize aktifliği en üstteki görünür pencereye devrediyor. toggle-desktop (Win+D) görünür pencereleri desktopSnapshot'a kaydedip küçültüyor; ikinci basışta yalnızca onlar geri geliyor. Not Defteri'nde aynı dosyayı ikinci kez açarsan yeni pencere açılmıyor, mevcut pencere öne geliyor ve params nesnesi değişmiyor. Tarayıcı yeniden boyutlanınca da requestAnimationFrame ile gönderilen bir reconcile pencereleri ekrana sığdırıyor.

Pencereyi kenara yapıştırmak

Snap sınırlarını aynı dosyada hesaplıyorum:

const snapBounds = (layout: SnapLayout, area: WorkArea): WindowBounds => {
  const half = Math.round(area.width / 2);
  const third = Math.round(area.width / 3);
 
  switch (layout) {
    case "left-half":
      return { x: 0, y: 0, width: half, height: area.height };
    // ...
    case "center-third":
      return { x: third, y: 0, width: third, height: area.height };
    // ...

SnapLayout yedi düzen tanıyor: yarımlar, üçte birler ve üçte ikiler. Büyüt düğmesinin üzerine gelince açılan menüde bunların dördü var (Sol Yarı, Sağ Yarı, Sol Üçte Bir, Sağ Üçte İki). center-third, right-third ve left-twothird'ü arayüzde çağıran bir yer yok. Snap'lenen pencere snapLayout alanını taşıdığı için reconcile onu yeni ekran boyutuna göre yeniden hesaplayabiliyor. Pencereyi Win+←/→ ile ya da kenara sürükleyerek de yapıştırabilirsin. İmleç üst kenara 8 px, yan kenarlara 10 px yaklaşınca createPortal ile bir önizleme çiziliyor.

Büyütülmüş ya da snap'lenmiş bir pencereyi de sürükleyebilirsin: 5 px hareketten sonra pencere eski genişliğine dönüyor ve tuttuğun nokta oransal olarak imlecin altında kalıyor. Boyutlandırma için sekiz tutamaç var, en küçük boyut 280×200; büyütülmüş pencerede tutamaçları hiç render etmiyorum. Sürükleme sırasında her pointermove bir move action'ı gönderiyor. Chrome penceresindeki iframe fare olaylarını yutmasın diye de içeriğin üstüne bir iframe-shield koyuyorum.

Bir uygulama çökünce masaüstü gitmesin

19 uygulamanın kaydını iki dosyaya böldüm. src/appCatalog.ts yalnızca meta veriyi tutuyor: id, title, varsayılan width/height ve singleInstance. Hesap Makinesi, Ayarlar ve Görev Yöneticisi dahil on iki uygulama tek örnekli, yani tek bir pencerede açılıyor. Dosya Gezgini, Chrome, Not Defteri, Terminal, Paint, VS Code ve resim görüntüleyici birden fazla pencere açabiliyor. src/appRegistry.tsx ise her appId'yi lazy yüklenen bir bileşene eşliyor:

function wrapApp(
  loader: () => Promise<{ default: ComponentType<AnyProps> }>,
  pickProps: (host: AppHostProps) => AnyProps = () => ({}),
): LazyExoticComponent<ComponentType<AppHostProps>> {
  return lazy(async () => {
    const mod = await loader();
    const Inner = mod.default as ComponentType<AnyProps>;
    const Wrapped: ComponentType<AppHostProps> = (host) => (
      <Inner {...pickProps(host)} />
    );
    // ...
    return { default: Wrapped };
  });
}

Her uygulama ayrı bir chunk ve ancak ilk penceresi açılınca iniyor. pickProps her uygulamaya yalnızca ihtiyacı olanı geçiriyor: Dosya Gezgini params, Not Defteri winId ve params alıyor, Hesap Makinesi ise hiçbirini almıyor. Pencere içeriğini çizen AppHost da aynı dosyada; her uygulamayı kendi hata sınırına ve kendi Suspense'ine sarıyor:

    <AppErrorBoundary appId={win.appId}>
      <Suspense
        fallback={
          <div className="app-loading-fallback" role="status">
            Yükleniyor…
          </div>
        }
      >
        <Comp
          // ...
        />
      </Suspense>
    </AppErrorBoundary>

AppErrorBoundary'nin yorumu neden orada olduğunu anlatıyor: çöken bir uygulama ya da yüklenemeyen bir chunk masaüstünü unmount etmesin, sorun kendi penceresinde kalsın. Kayıtta olmayan bir appId için de "Bilinmeyen uygulama" mesajı çıkıyor.

Kayıtta eskiden kalma bir kimlik de var. Arayüzde "Google Chrome" diye görünen tarayıcının appId'si hâlâ edge; README de bunu "tarihsel kimlik" diye not ediyor. Görünen adı Chrome yaptığımda FSContext'e bir göç ekledim: localStorage'daki eski "Edge Tarayıcı" masaüstü düğümünü "Google Chrome" olarak yeniden adlandırıyor. appId'yi değiştirmek bir göç daha gerektirirdi, çünkü kaydedilmiş pencereler ve kısayollar bu kimlikle saklanıyor. O göçü yapmadım.

Dosya sistemi tek bir ağaç

Düğüm tipi src/context/FSContext.tsx'te:

export type NodeType = "file" | "dir" | "app" | "shortcut";
 
export interface VFSNode {
  name: string;
  type: NodeType;
  content?: string; // Text content for files
  appId?: string; // App key (e.g. 'notepad', 'edge') for shortcuts
  url?: string; // Target URL for browser shortcuts
  // ...
  children?: { [name: string]: VFSNode }; // Subnodes for directories
  x?: number; // Custom desktop X coordinate (if located on Desktop)
  y?: number; // Custom desktop Y coordinate (if located on Desktop)
  updatedAt: string;
}

Dört düğüm tipi var: dosya, klasör, uygulama kısayolu ve Chrome'da açılan web kısayolu (shortcut). Çocukları dizi yerine isimden düğüme giden bir haritada tutuyorum; yollar da segment dizileri. Ev klasörü C:/Users/AhmetFarukUzunkaya. Eski kayıtlarda JohnDoe, Mirus ya da User adlı bir ev klasörü varsa sayfa yüklenirken yeni ada taşınıyor. Masaüstü ikonlarının konumu için ayrı bir state yok, konum düğümün x/y alanlarında duruyor.

İşlemlerin bir kısmı src/vfs/vfsOps.ts'te saf fonksiyon olarak duruyor. createNodeInTree, ebeveyn yoksa ya da o ad zaten alınmışsa aynı kök referansını döndürüyor. setFs aynı nesneyi alınca React render etmiyor, kayıt effect'i de tetiklenmiyor. FSContext'in var olan düğümlerin üzerine yazdığı hatayı 1 Ekim'deki commit'te düzelttim ve aynı commit'e bunu sabitleyen bir vfsOps testi ekledim. Ama bu ayrımı yarım bıraktım. 82 satırlık dosyada yalnızca oluşturma, yeniden adlandırma ve yardımcılar var. Silme, kopyalama/taşıma, içerik güncelleme ve ikon konumları hâlâ 815 satırlık FSContext.tsx içinde; bunlar ağacın tamamını structuredClone ile kopyalayıp kopyayı yerinde değiştiriyor.

Kayıt iki farklı tempoda yapılıyor. Dosya sistemi her değişiklikte, debounce olmadan, bütünüyle win11_vfs'e yazılıyor. Pencere oturumunu ise WindowContext.tsx'te 180 ms debounce ile yazıyorum. Sürükleme her pointermove'da bir action ürettiği için debounce olmasa her piksel ayrı bir yazma demek olurdu:

    const timer = window.setTimeout(() => {
      try {
        const serializableWindows = state.windows.map((item) => ({
          ...item,
          params: item.params
            ? { ...item.params, content: undefined }
            : undefined,
        }));
        localStorage.setItem(
          "win11_windows",
          JSON.stringify(serializableWindows),
        );
      // ...
    }, 180);

Not Defteri'nin içeriğini (params.content) bu kayda katmıyorum, çünkü metin zaten VFS'te duruyor. Sayfa yüklenirken normalizeWindows bozuk ya da eski kayıtları ayıklıyor, pencereleri ekrana sığdırıyor ve z-index'leri yeniden numaralıyor.

51 test neyi kapsıyor

Testler Vitest ile node ortamında, DOM olmadan çalışıyor. Dağılım şöyle: projectsCatalog 10, browserBookmarks 9, wallpapers 6, calculatorLogic 5, windowReducer 5, desktopGrid 4, taskbarEntries 4, vfsOps 4, openNode 2, networkState 2. Reducer testleri içinde bence en ilginci, Not Defteri'nin aynı dosya için ikinci bir pencere açmamasını sabitleyen test. Onu Türkçe karakterli dosya adları için yazdım (src/context/windowReducer.test.ts):

    let state = open(createEmptyWindowState(), "n:1", "Doğa.txt");
    state = open(state, "n:2", "Doça.txt");
    state = open(state, "n:3", "doğa.txt");
    expect(state.windows.map((w) => w.id)).toEqual(["n:1", "n:2", "n:3"]);
    const paramsBefore = state.windows[0].params;
    state = open(state, "n:4", "Doğa.txt");
    expect(state.windows).toHaveLength(3);
    expect(state.activeWindowId).toBe("n:1");
    // Same object → Notepad's [params] effect doesn't reload over unsaved edits.
    expect(state.windows[0].params).toBe(paramsBefore);

"Doğa", "doğa" ve "Doça" ayrı dosyalar olarak ele alınıyor. Aynı yol ikinci kez açılınca mevcut pencere öne geliyor; params aynı referans kaldığı için de Not Defteri kaydedilmemiş metni ezmiyor. Diğer reducer testleri pencere açmayı, aktif pencereyi kapatmayı, sol yarıya snap'i ve birden çok Chrome penceresini kapsıyor. vfsOps testleri orijinal ağacın değişmediğini ve var olan bir düğümün üzerine asla yazılmadığını doğruluyor.

Henüz düzeltmediklerim

Sayfayı yenilediğinde pencerelerin geri geliyor, ama bootStage her zaman "bios" ile başladığı için önce yaklaşık 14,7 saniyelik açılışın tamamını yeniden izliyorsun. Parola hâlâ kontrol edilmiyor ve yedi snap düzeninin üçüne arayüzden ulaşılamıyor. Reducer testlerinde odaklama ve küçültme yok. Kota dolunca yayınlanan vfs-storage-error olayını şu an dinleyen bir bileşen de yok, hata yalnızca konsola düşüyor. Bir de masaüstünde "Simgeleri otomatik hizala" varsayılan olarak açık ve bu modda ikonlar sürüklenemiyor; 100×110 piksellik ızgarada ikonları serbestçe yerleştirmek istersen bunu menüden kapatman gerekiyor.

Projenin sayfasına ve kaynak koduna göz atabilirsin. Diğer yedi tarayıcı işletim sistemiyle karşılaştırmayı ayrı bir yazıda yaptım.