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

Tarayıcıda sekiz işletim sistemi yazdım, her birinde başka bir state modeli çıktı

Pencere yöneticisini, dosya sistemini ve uygulama kaydını beş masaüstünde sıfırdan kurdum ve her seferinde işe başka bir yerden girdim. "Kaydetmeden kapatılsın mı?" sorusunu da üç ayrı şekilde çözdüm. Ubuntu'nun kabuğu yan etkilerini geri veriyor, WebDroid intent'leri WebSocket üzerinden taşıyor, iPhone'un ekranı da bir 3D modelin üstüne oturtulmuş canlı DOM.

  • React
  • Mimari
  • Sanal Dosya Sistemi
  • Durum Yönetimi
Tarayıcıda sekiz işletim sistemi yazdım, her birinde başka bir state modeli çıktı

Tarayıcıda sekiz işletim sistemi arayüzü yazdım: Windows 11, Windows XP, Windows 8.1, macOS Tahoe, Ubuntu 24.04, Android 15, iOS 26 ve bir oyun konsolu. Masaüstü olan beşinde aynı üç parçayı her seferinde sıfırdan kurdum: pencere yöneticisi, sanal dosya sistemi ve uygulama kaydı. Kodları yan yana açınca en çok dikkatimi çeken şey, aynı problemi her projede başka bir state modeliyle çözmüş olmam oldu. Windows 11'de pencereler bir useReducer içinde, XP ve macOS'ta bir zustand store'unda duruyor. Windows 8.1'de ise React'in tamamen dışındalar, elle yazdığım bir store'dalar.

Hangi pencere önde?

Pencere yöneticisi her masaüstünde aynı üç soruya cevap veriyor: hangi pencere önde, hangisi odakta, hangisi küçültülmüş. Windows 11, XP, macOS ve Ubuntu'da her pencere kendi z değerini taşıyor ve öne getirdiğin pencere en büyük değerin bir fazlasını alıyor. Windows 8.1'de sayı tutmadım; orada z-sırası id'lerden oluşan bir dizi. Store'u neden React'in dışına koyduğumu lib/wm.ts'in başına yazdım:

/**
 * Desktop window manager: the open windows, their stacking order and focus.
 * Lives outside React (an external store) so the windows survive trips to Start, and so
 * dragging one window doesn't re-render the whole OS.
 */
// ...
type State = { wins: Win[]; order: string[]; focus: string | null };

Bileşenler bu store'a useSyncExternalStore ile bağlanıyor. order dizisinin son elemanı en üstteki pencere, odak ayrı bir alanda duruyor. XP'de odak diye bir alan hiç yok; küçültülmemiş pencereler arasında z değeri en yüksek olan aktif sayılıyor. Ubuntu'da sayaçta karar kıldım, çünkü src/os/windows.ts'teki yorumda yazdığı gibi DOM sırası hiç değişmiyor, iframe'ler ve terminaller de yeniden mount edilmiyor.

Sürüklemenin derdi şu: dikkat etmezsen her pikselde bütün arayüz yeniden render ediliyor. Bunu üç projede üç ayrı yoldan engelledim. XP'de her pencere memo ile sarılmış bir react-rnd öğesi ve store'a yalnızca onDragStop anında yazıyorum. Windows 8.1'de pointermove sırasında elemanın style.left/top değerlerini doğrudan değiştiriyorum, store pointerup'ta tek bir wm.patch ile güncelleniyor. Ubuntu'da sürükleme boyunca yalnızca CSS transform değişiyor.

Kalıcılık da projeden projeye değişiyor. Windows 11 açık pencereleri localStorage'a yazıyor. XP ise sessionStorage kullanıyor ve Log Off'a basınca onları siliyor. Windows 8.1, macOS ve Ubuntu'da pencereleri hiç kaydetmiyorum.

Dosyalar nerede duruyor

Uygulamaların aynı dosyaları görmesi için iki ayrı yapı kullandım. Windows 11 ve Windows 8.1'de iç içe bir ağaç var. XP, macOS ve Ubuntu'da ise düz bir harita; anahtarı parentId ya da yol. İkincisinin en yalın hali Ubuntu'daki src/os/fs.ts:

// Virtual file system: a flat Record keyed by absolute path. Every op is pure — it returns a new
// Fs or throws FsError with the Linux errno text. Trash follows the freedesktop layout:
// ...
export type FsNode = { type: "file" | "dir"; content?: string; mime?: string; mtime: number; size: number };
export type Fs = Record<string, FsNode>;

Her işlem yeni bir Fs döndürdüğü için kabuk, dosya sisteminin bir kopyası üzerinde çalışabiliyor; Terminal de sonucu tek bir updateFs çağrısıyla store'a yazıyor. Files ve Text Editor aynı fs dilimine abone. Bu yüzden Terminal'de sildiğin klasör Files'ta açıksa pencere en yakın üst klasöre dönüyor. echo x > a.txt yazarsan da editörde o dosyanın kaydedilmemiş değişikliği olmayan sekmesi yeniden yükleniyor. Düz haritanın bir bedeli var, onu da koda not ettim: list() bir dizini listelemek için bütün anahtarları tarıyor.

Dosyaları tarayıcıda tutan beş projenin hepsi diski tek bir JSON parçası olarak saklıyor ve çoğu localStorage'a yazıyor. macOS'ta IndexedDB kullandım, çünkü src/os/idb.ts'teki yorumda yazdığı gibi içe aktarılan resim ve sesler yaklaşık 5 MB'lık kotayı çabuk dolduruyor. Bu adaptör başta başka bir dosyanın içindeydi. macOS'u fazlar halinde yazdım; altıncı fazda aşırı büyümüş dosyaları bölerken onu da ayrı bir dosyaya aldım.

Uygulama kaydında da mantık aynı: herkes ortak bir kaynağa bakıyor. Ubuntu'da Dock, uygulama ızgarası, arama ve "Birlikte aç" menüsü tek bir listeyi okuyor (src/apps/registry.ts); yeni bir uygulama eklemek için bir kayıt ve bir bileşen dosyası yetiyor. Hangi uygulamanın hangi dosyayı açacağına karar veren yer ise her projede başka. macOS ve Ubuntu'da bu bilgi kayıttaki MIME desenlerinde, XP'de kayıttaki noktasız uzantılarda duruyor. Windows 11'de openNode.ts içindeki bir regex karar veriyor, Windows 8.1'de düğümün kind alanına bakan bir switch, WebDroid'de de intent filtreleri.

Hepsini tek tabloya dökünce şöyle görünüyor:

ProjePencere state'iDosya sistemiKalıcılıkLazy loadingTest
Windows 11useReducer + Contextiç içe ağaçlocalStorage win11_vfsReact.lazyVitest, 10 dosya / 51 test
Windows XPzustanddüz harita + parentIdlocalStorage xp-fsyokbun test, 6 dosya / 24 test
Windows 8.1elle yazılmış external storeiç içe ağaçlocalStorage afu-metro:v2:fsyokyok
macOSzustanddüz harita + parentIdIndexedDB macos-webReact.lazybun test, 5 dosya / 31 test
Ubuntuzustandyolla anahtarlanmış haritalocalStorage ubuntu-reactlazy()bun test, 11 dosya / 68 test + e2e
WebDroidgörev/aktivite yığınısunucuda files tablosuSQLite data/webdroid.sqliteyokyok
iPhoneopenApp + son kullanılanlaryoklocalStorage iphone-oslazy()bun test, 5 dosya / 22 test
Konsolekran yığını + girdi katmanlarıyoklocalStorage afu-console.v1kayıt yokyok

Hesap makinesinin bileşende işi yok

Hesap makinesinin ya da mayın tarlasının bir bileşenin içinde yaşaması için bir sebep göremedim. XP'deki Hesap Makinesi, press(state, key) biçiminde saf bir durum makinesi ve orijinali gibi soldan sağa hesaplıyor: "2 + 3 ×" tuşlarına basarsan ekranda 5 yazıyor. Mayın Tarlası'nda mayınları ilk tıklamandan sonra yerleştiriyorum (src/lib/minesweeper.ts):

/** Mines go in after the first click, never under it, so the opening move is always safe. */
function plant(b: Board, safe: number, rand: () => number): Cell[] {
  const cells = b.cells.map((c) => ({ ...c }));
  const spots = cells.map((_, i) => i).filter((i) => i !== safe);
  for (let k = 0; k < b.mines && spots.length; k++) {
    const j = Math.floor(rand() * spots.length);
    cells[spots[j]!]!.mine = true;
    spots[j] = spots[spots.length - 1]!;
    spots.pop();
  }
  // ...
}

Seçilen konum listeden çıktığı için aynı hücre iki kez seçilemiyor. rand'i dışarıdan aldığım için test, tohumlanmış bir Lehmer üreteciyle 199 tahta deneyip mayın sayısını ve ilk tıklanan hücrenin boş kaldığını kontrol edebiliyor. Ubuntu'daki src/lib/mines.ts'te bir adım daha attım: tıklanan hücrenin komşuları da mayınsız, yani ilk tık her zaman bir boşluk açıyor.

Seslerde de hep geri döndüğüm bir tercih var. Windows 8.1 ve konsol depolarında tek bir ses dosyası bulamazsın, çünkü efektleri osilatörlerle üretiyorum. ConvolverNode'un normalde bir dosyadan okuduğu oda yanıtını bile kodda, üstel sönümlenen beyaz gürültüden oluşturuyorum. XP önce public/sounds/ altında dosya arıyor, bulamazsa sentezliyor. WebDroid ve iPhone'un arayüz sesleri de sentez. macOS'ta Web Audio hiç yok.

"Kaydetmeden kapatılsın mı?"

Pencereyi kabuk kapatıyor, kaydedilmemiş metni ise uygulama biliyor. Bu ikisini XP, Windows 8.1 ve macOS'ta üç farklı şekilde konuşturdum.

XP'de bunu global bir diyalog yığını ve bir Promise ile çözdüm. msgBox bir zustand store'una kayıt ekleyip Promise döndürüyor; <MessageBoxHost /> da yığının yalnızca en üstündekini çiziyor. Kaydetme sorusu bunun üstüne kurulu (src/components/shell/MessageBox.tsx):

/** XP's Yes/No/Cancel save prompt: true when the caller may go on (saved, or the changes were dropped). */
export async function askSave(text: string, title: string, save: () => Promise<boolean>) {
  const answer = await msgBox(text, { title, kind: "warning", buttons: ["Yes", "No", "Cancel"] });
  return answer === "No" || (answer === "Yes" && (await save()));
}

Not Defteri bu fonksiyonu useCloseGuard(win.id, settle) ile kaydediyor, requestClose da pencereyi kapatmadan önce guard'ı bekliyor. Böylece uygulama tarafındaki kod bir confirm() çağrısı kadar düz kalıyor. useCloseGuard'ın effect'ine bağımlılık dizisini bilerek koymadım; yorumda yazdığı gibi her render'da yeniden kaydolmak closure'ı taze tutuyor.

Windows 8.1'de diyalog, sahibi olan gerçek bir pencere: pencere yöneticisinde app: "dialog" olarak açılıyor ve bir owner alanı taşıyor. Sahibine tıklarsan odak diyaloğa geçiyor, sahibi kapanınca diyalog da kapanıyor. Not Defteri'ndeki askSave, buton indeksiyle çözülen bir Promise döndürüyor:

    const id = openDialog(owner, {
      // ...
      render: (close) => (
        <SaveTask
          // ...
          buttons={tr ? ["Kaydet", "Kaydetme", "İptal"] : ["Save", "Don't Save", "Cancel"]}
          onPick={(i) => {
            pick(i);
            close();
          }}
        />
      ),
    });
    onGone(id, () => pick(2));

Diyaloğu X ile kapatırsan onGone bunu İptal sayıyor. Guard'ı kaydeden hook burada closure'ı useEffectEvent ile güncel tutuyor.

macOS'ta Promise kullanmadım, kapanmayı pencerenin durumuna bağladım. Pencerede iki alan tutuyorum, dirty ve confirmClose (src/os/windows.ts):

    requestClose: (id) => {
      const win = get().wins.find((w) => w.id === id);
      if (win?.dirty) {
        get().focus(id);
        patch(id, { confirmClose: true });
      } else {
        get().close(id);
      }
    },

confirmClose true olunca Window.tsx pencerenin içine bir <Sheet> çiziyor. Kaydet düğmesi pencereye "save-and-close" komutunu gönderiyor. TextEdit komutu alıp dosyayı yazıyor; dosyanın henüz bir yolu yoksa önce Farklı Kaydet'i açıyor. Bu yaklaşımın bedelini Win tipinin yorumuna yazdım: dirty'yi işaretleyen her uygulama bu komutu karşılamak zorunda.

Üçünü yan yana koyunca uygulama kodu en kısa XP'de kalıyor, modal davranışta gerçeğine en çok Windows 8.1 yaklaşıyor, macOS'ta da kapanma durumu baştan sona store'da duruyor.

Tek bir projede karşıma çıkanlar

Ubuntu'nun kabuğu yan etki çalıştırmıyor

src/lib/shell.ts'teki asıl kararı dosyanın ilk satırlarına yazdım:

// Pure shell interpreter: run(input, ctx) → { output, cwd, fs, effects, code }. Input is tokenized
// (quotes, escapes, operators), split into `&&` chains of `|` pipelines; each command gets the previous
// stdout as stdin. Side effects the UI must perform (open app, clear, exit) are returned, never executed.
// ...
export type Effect =
  { type: "open"; appId?: string; path?: string; args?: string[] } | { type: "clear" } | { type: "exit" };

run() uygulama açmıyor, ekranı temizlemiyor, çıkış da yapmıyor. Bunları Effect[] olarak geri veriyor, Terminal'deki apply metodu da onları xterm'e ya da openApp'e çeviriyor. Ubuntu'da en sevdiğim karar bu oldu; faydası da en çok testte görünüyor: gedit notes.txt komutunun döndürdüğü etkiyi tek bir nesneyle karşılaştırabiliyorum, { type: "open", appId: "text-editor", path }.

WebDroid'de uygulamalar birbirini tanımıyor

WebDroid'de hiçbir uygulama diğerini import etmiyor. Kişiler uygulaması bir fotoğraf istediğinde await os.startActivity({ action: "GET_CONTENT", type: "image/*" }) çağırıyor, Galeri de seçimi os.finish ile geri döndürüyor. Bunu Android'deki startActivityForResult'ın Promise'li hali gibi düşünebilirsin. İki sekmenin iki ayrı telefon gibi davranması için URL'ye ?device=b eklemen yetiyor; ikinci sekme ayrı bir cihaz kimliği alıyor. Cihazdan cihaza giden mesajları (farklı makineler arasında bile) Bun'ın WebSocket pub/sub'ı taşıyor; taşınan şey de yine bir intent (src/server.ts):

        const body = JSON.stringify({ from: ws.data.number, intent: msg.intent });
        const known = knownNumber(msg.to);
        const online = server.subscriberCount(`dev:${msg.to}`) > 0;
        if (online) server.publish(`dev:${msg.to}`, body);
        else if (msg.queue && known) db.query("INSERT INTO outbox (number, body) VALUES (?, ?)").run(msg.to, body);

Alıcı çevrimdışıysa ve mesaj kuyruğa alınabilecek türdense (SMS gibi) outbox tablosuna düşüyor ve cihaz bağlanınca teslim ediliyor. Sunucudaki bütün veri tek bir dosyada duruyor: data/webdroid.sqlite. Cihazları kv ve files tablolarındaki device sütunu ayırıyor.

iPhone'da ekran, 3D modelin üstünde canlı DOM

Telefonun kendisi Sketchfab'den aldığım, CC BY 4.0 lisanslı bir iPhone 15 Pro Max GLB modeli. Ekran mesh'lerini ve Dynamic Island'ı gizledim, yerlerine drei'nin <Html> bileşeniyle sahneye yerleştirilmiş canlı bir React ağacı koydum (src/scene/Phone.tsx):

      <Html
        ref={layer}
        wrapperClass={SCREEN_LAYER}
        transform
        occlude="blending"
        // ...
        portal={portal}
      >
        {screen}
      </Html>

Aynı <Shell /> düz modda da render ediliyor. Bu kurulumda iki derde çözüm bulmam gerekti. Birincisi metnin titremesiydi: Chrome, kamera hareket ettikçe katmanı her yeni ölçekte yeniden rasterliyor. Hareket sırasında will-change: transform veriyorum, kamera 180 ms durunca da kaldırıyorum, o an tek bir net raster alınıyor. İkincisi StrictMode'du: <Html> kendi iç React kökünü taşıyor ve StrictMode'un iki kez çalışan effect'i bu kökü söküyor, o yüzden StrictMode'u bilerek kapattım. Dokunmatik cihazlarda düz mod varsayılan. Scene lazy() ile yüklendiği için three.js telefonlara hiç inmiyor.

Konsolun ana ekranında benim projelerim yok

Konsoldaki content/portfolio.ts Windows 8.1'dekiyle aynı beş dışa aktarımı kullanıyor, ama ana ekrandaki oyunlar benim projelerim değil:

export const projects = games;

content/games.ts, scripts/sync-igdb.mjs'in IGDB'den ürettiği gerçek PS5 oyunlarını tutuyor. Bu script'i ve depolarımı çeken sync-github.mjs'i, arayüz commit'lerinden dört gün sonra ekledim. Depolarım "İşe alım" kullanıcısının profilinde listeleniyor. Repoda ses, ikon ya da logo dosyası yok. Her oyunun teması, id'sinin FNV-1a hash'ine göre seçilen sentezlenmiş bir akor; ikonlar satır içi SVG. Oyun görselleri çalışma zamanında images.igdb.com'dan geliyor.

Henüz iyi olmayanlar

Windows 8.1, WebDroid ve konsolda test yok; WebDroid'deki test dosyalarını son commit'te kaldırdım. Tarayıcıdaki dosya sistemleri tek parça JSON olduğu için her değişiklikte bütün disk yeniden serileştiriliyor. localStorage kotası da gerçek bir sınır: Ubuntu kotanın yüzde 80'ine gelince uyarıyor, XP ise yazma başarısız olursa değişikliği bellekte tutup bir storage-full olayı gönderiyor. WebDroid'deki RTCPeerConnection STUN ya da TURN sunucusu olmadan kuruluyor, yani aramalar aynı makinede ya da yerel ağda kalıyor. Konsolun ana ekranı da IGDB'ye, YouTube'a ve Google Fonts'a erişebilmeye bağlı.

Windows 11'in açılış akışını ve reducer'ını ayrı bir yazıda anlattım.