Skip to content

Latest commit

 

History

History
298 lines (247 loc) · 21.7 KB

File metadata and controls

298 lines (247 loc) · 21.7 KB

Deck — API Sözleşmesi (implementasyon kontratı)

Bu dosya paralel yazılan dosyaların birbirine uymasını sağlayan KESİN sözleşmedir. İmzaları birebir uygula; ekleme yapabilirsin ama burada geçen isim/imzaları DEĞİŞTİRME. Modeller Sources/Deck/Models/Models.swift içinde HAZIR (Project, IconSpec, CanvasItem, ItemKind, TerminalMode, ServiceStatus, TabKind, WorkspaceTab, ClaudeSession) — oku ve kullan.

Genel kurallar:

  • Swift 5.9, macOS 14+, tek modül Deck. Dış bağımlılık yalnız SwiftTerm.
  • UI metinleri Türkçe; kod tanımlayıcıları İngilizce. Yorum yalnız gerekli yerde.
  • Tüm ObservableObject'ler @MainActor. Disk/proses işleri arka planda, @Published güncellemeleri main'de.

ServiceStatus — Models.swift'te güncellenecek hali (canvas rozetleri için)

ServiceStatus'a externalRunning ve crashed(exitCode: Int32) dahildir:

enum ServiceStatus: Equatable {
    case stopped, starting, running, externalRunning, stopping
    case crashed(exitCode: Int32)
    var isRunning: Bool   // running/starting/stopping/externalRunning
    var isOwnedByDeck: Bool
    var color: Color      // yeşil/açık yeşil(external)/sarı/gri/kırmızı
    var label: String     // Türkçe
}
enum ClaudeAttention: Equatable { case working, waiting }

DeckPaths.swift (Services/)

enum DeckPaths {
    static let appSupport: URL        // ~/Library/Application Support/Deck (init'te create)
    static let projectsFile: URL      // appSupport/projects.json
    static let claudeTabsFile: URL    // appSupport/claude-tabs.json
    static let claudeStateDir: URL    // appSupport/claude-state (create)
    static let tmuxConfig: URL        // appSupport/deck-tmux.conf
    static let hookScript: URL        // ~/.claude/hooks/deck-hook.sh
    static let claudeSettings: URL    // ~/.claude/settings.json
}

Shell.swift (Services/)

enum Shell {
    static let userPath: String                       // lazy: /bin/zsh -l -i -c 'print -rn -- $PATH'; hata → ProcessInfo PATH
    static func findExecutable(_ candidates: [String]) -> String?
    static func singleQuoted(_ s: String) -> String   // 'abc' sarma; içteki ' → '\''
    @discardableResult
    static func run(_ launchPath: String, _ args: [String]) -> (status: Int32, output: String)   // sync, stdout+stderr birleşik
    static func runAsync(_ launchPath: String, _ args: [String], completion: (@Sendable (Int32, String) -> Void)? = nil)
    static func runDetached(_ command: String)        // /bin/zsh -c, fire-and-forget
}

TmuxService.swift (Services/)

enum TmuxService {
    static let socketPath: String            // "/tmp/deck-tmux-\(getuid()).sock"
    static var tmuxPath: String?             // findExecutable: /opt/homebrew/bin, /usr/local/bin, /opt/local/bin, /usr/bin
    static var isAvailable: Bool
    static func ensureConfig()               // deck-tmux.conf'u yaz (DESIGN.md'deki minimal içerik)

    struct Session: Identifiable, Equatable {
        var id: String { name }
        let name: String; let projectID: String; let number: Int
        let customName: String?; let claudeSID: String?; let attached: Bool
    }
    static func listSessions() -> [Session]                       // @deck_project dolu olanlar
    static func hasSession(_ name: String) -> Bool
    static func kill(_ name: String)
    static func setOption(_ session: String, key: String, value: String)   // set-option -t <s> @key value
    static func paneTitles() -> [String: String]                  // session_name → pane_title
    static func scroll(_ session: String, lines: Int, up: Bool)   // copy-mode -e + send-keys -X
    /// zsh -l -i -c ile çalıştırılacak tam string üretir:
    /// exec '<tmux>' -S '<sock>' -f '<conf>' new-session -A -D -s '<name>' -x <cols> -y <rows> [-e K=V]... ['<inner>']
    static func attachCommand(session: String, cols: Int, rows: Int, env: [String: String], inner: String?) -> String
}

ProjectStore.swift (Services/)

@MainActor final class ProjectStore: ObservableObject {
    @Published var projects: [Project]
    init()                                        // load; dosya yoksa []
    func project(_ id: UUID) -> Project?
    func addProject(name: String, path: String) -> Project   // canvas'a otomatik sabit Claude ikonu ekler (kind: .claude, name: "Claude", icon: .claude, x:40 y:40)
    func updateProject(_ p: Project)
    func deleteProject(_ id: UUID)
    func upsertItem(_ item: CanvasItem, in projectID: UUID)
    func removeItem(_ itemID: UUID, from projectID: UUID)
    func moveItem(_ itemID: UUID, in projectID: UUID, to point: CGPoint)  // x/y güncelle + save (save debounce'lu olabilir)
    func save()                                   // atomik: tmp + move; {"version":1,"projects":[...]}
}

ClaudeTabStore.swift (Services/)

claude-tabs.json: { "<projectID>": { "counter": Int, "closed": [ClosedTab] } }

@MainActor final class ClaudeTabStore: ObservableObject {
    struct ClosedTab: Codable, Equatable, Identifiable {
        var id: Int { number }
        let number: Int; var name: String?; var claudeSID: String?; var title: String?
    }
    @Published private(set) var closed: [UUID: [ClosedTab]]
    func nextNumber(for projectID: UUID) -> Int          // monoton sayaç, kalıcı
    func bumpCounter(for projectID: UUID, atLeast n: Int)
    func recordClosed(_ tab: ClosedTab, for projectID: UUID)   // başa ekle, max 30
    func removeClosed(number: Int, for projectID: UUID)
    func clearClosed(for projectID: UUID)
}

ClaudeSessionService.swift (Services/)

enum ClaudeSessionService {
    /// ~/.claude/projects/<encoded>/*.jsonl tara. encoded = mutlak yolda '/' → '-'.
    /// UUID adlı dosyalar; preview: type=="user" ilk gerçek mesaj (envelope filtreli), 200 kr;
    /// 64KB chunk, max 4MB, max 10 aday. mtime sıralı (yeni → eski). Arka planda çağrılır (sync API yeterli).
    static func scan(cwd: String) -> [ClaudeSession]     // ClaudeSession: Models.swift (id, summary, lastActivity, fileSizeBytes ekle)
    /// "claude --resume '<id>'" [--fork-session] + opsiyonel son argüman initial prompt (hepsi singleQuoted)
    static func resumeCommand(_ opts: ClaudeResumeOptions) -> String
}
struct ClaudeResumeOptions: Equatable {
    var sessionID: String; var fork: Bool = false; var initialPrompt: String? = nil
}

HookInstaller.swift (Services/)

enum HookInstaller {
    /// deck-hook.sh'ı yaz (0755) + ~/.claude/settings.json'a idempotent merge.
    /// Eventler stoker'daki gibi (PreToolUse/PostToolUse/UserPromptSubmit → working,
    /// Notification/Stop → waiting, SessionStart → working, SessionEnd → dosya sil).
    /// Script: DECK_TAB_ID yoksa exit 0; stdin'i TAMAMEN oku; session_id'yi sed ile çek;
    /// claude-state/<DECK_TAB_ID>.json'a atomik yaz: {"state","name","project","sid","ts"}.
    /// Parse edilemeyen settings.json'a DOKUNMA. Arka plan kuyruğunda çağır.
    static func installIfNeeded()
}

NotificationService.swift (Services/)

enum NotificationService {
    /// osascript display notification (UNUserNotificationCenter KULLANMA — ad-hoc imzalı app).
    static func notify(title: String, subtitle: String, body: String, sound: String)  // default sound "Glass"
    static func setBadge(_ count: Int)    // NSApp.dockTile.badgeLabel; 0 → nil. MainActor.
    static func playSound(_ name: String) // NSSound(named:)
}

ProcessManager.swift (Services/)

Kalbi. SwiftTerm LocalProcessTerminalView cache'i + servis yaşam döngüsü + claude tmux + attention watcher.

final class DeckTerminalView: LocalProcessTerminalView { var tmuxSession: String? }

@MainActor final class ProcessManager: NSObject, ObservableObject, LocalProcessTerminalViewDelegate {
    @Published var statuses: [UUID: ServiceStatus] = [:]        // servis item id
    @Published var attention: [UUID: ClaudeAttention] = [:]     // workspace tab id
    @Published var paneTitles: [String: String] = [:]           // tmux session → başlık (2sn timer)
    weak var projectStore: ProjectStore?
    weak var tabStore: ClaudeTabStore?

    func status(of itemID: UUID) -> ServiceStatus               // default .stopped
    /// Anahtar: servisler için item.id.uuidString, sekmeler için tab.id.uuidString.
    func terminalView(forKey key: String) -> DeckTerminalView   // yoksa yarat (frame 1200x720, mono 12pt, scrollback 10k, processDelegate=self)
    func hasTerminalView(forKey key: String) -> Bool

    // Servisler (doğrudan PTY — stoker ProcessManager deseni birebir)
    func startService(_ item: CanvasItem, project: Project)
    func stopService(_ item: CanvasItem)
    func restartService(_ item: CanvasItem, project: Project)
    func toggleService(_ item: CanvasItem, project: Project)
    func killPort(_ port: Int, feedbackKey: String?)
    func scanExternalServices(projects: [Project])              // stopped + port bağlı → .externalRunning

    // Sekme terminalleri
    func startShell(tabID: UUID, cwd: String)                   // exec zsh -l -i (stty+cd wrapped)
    func runOneshot(tabID: UUID, command: String, cwd: String)  // bitince exit banner, process ölür
    /// Claude: tmux create-or-attach. session boşsa yeni ad üret (tab uuid). resume != nil ise inner "claude --resume ..."
    /// Env: DECK_TAB_ID=<tabID>, DECK_TAB_NAME, DECK_PROJECT. setOption ile @deck_project/@deck_num/@deck_name yazılır.
    func startClaude(tabID: UUID, project: Project, number: Int, customName: String?, resume: ClaudeResumeOptions?, existingSession: String?)
    func closeTab(tabID: UUID, killTmux: Bool)                  // view'i öldür/temizle; killTmux=true → tmux kill-session
    func sendInput(key: String, data: [UInt8])
    func repaint(key: String)                                   // softReset + refresh (reset DEĞİL)

    func terminateAllSync()                                     // tmux-backed HARİÇ; 0x03+killpg(SIGTERM)→2sn poll→SIGKILL
    // LocalProcessTerminalViewDelegate: processTerminated → exit 0 ise .stopped, değilse .crashed; pendingRestart 0.2sn sonra start
}

Detaylar (stoker'dan birebir port et):

  • Spawn wrapper: stty cols C rows R 2>/dev/null; cd '<escCwd>' && <cmd>; /bin/zsh -l -i -c.
  • Env: Shell.userPath, TERM=xterm-256color, COLORTERM=truecolor, LANG (yoksa en_US.UTF-8), TERM_PROGRAM=Deck, CLAUDE_CODE_NO_FLICKER=0, COLUMNS/LINES.
  • Readiness: 0.3sn poll; port TCP connect 100ms timeout (SO_SNDTIMEO/RCVTIMEO); 30sn timeout → yine .running; portsuz 1.5sn grace.
  • claude-state watcher: DispatchSource vnode + 1sn Timer poll; dosya <tabID>.json → attention güncelle; "waiting"e geçişte NotificationService.notify + ses + badge (badge = toplam waiting sayısı). SessionEnd dosyayı siler → attention temizle.
  • Scroll monitörü: NSEvent.addLocalMonitorForEvents(.scrollWheel); hedef view DeckTerminalView ve tmuxSession != nil ise TmuxService.scroll'a yönlendir, event'i yut.
  • startService başında stale state dosyasını sil; başlangıç/exit/ready ANSI banner'ları stoker'daki gibi.

WorkspaceStore.swift (Services/)

@MainActor final class WorkspaceStore: ObservableObject {
    @Published var tabs: [UUID: [WorkspaceTab]] = [:]        // projectID → sekmeler
    @Published var activeTab: [UUID: UUID] = [:]             // projectID → tab id
    @Published var workspaceOpen: [UUID: Bool] = [:]         // projectID → workspace görünür mü
    func tabs(for projectID: UUID) -> [WorkspaceTab]
    func addTab(_ tab: WorkspaceTab, to projectID: UUID, activate: Bool)
    func closeTab(_ tabID: UUID, in projectID: UUID)
    func select(_ tabID: UUID, in projectID: UUID)
    func renameTab(_ tabID: UUID, in projectID: UUID, to name: String?)
    /// Açılışta tmux'tan @deck_project == proje shortID olan oturumları sekme olarak geri getir.
    func adoptTmuxSessions(for project: Project, tabStore: ClaudeTabStore)
    func openWorkspace(_ projectID: UUID, _ open: Bool)
}

WorkspaceTab'a Models.swift'te şunlar dahil: id, kind, title, tmuxSession?, itemID?, url?, number: Int? (claude sekme numarası), customName: String?.

Skills — servisler (Services/)

Skill = <proje>/.claude/skills/<ad>/SKILL.md. Deck onu tekrarlanabilir bir rapor üreticisi olarak çalıştırır; çıktı <proje>/.deck/reports/<skill>/<YYYY-MM-DD-HHmm>.md.

Gerçeğin kaynağı disktir — ayrı bir "son çalışma" veritabanı YOKTUR. Son çalışma = en yeni raporun frontmatter run_at'i (yoksa mtime). Deck'in App Support'ta tuttuğu tek şey zamanlama tercihidir. Bu sayede launchd Deck kapalıyken rapor üretse bile senkronizasyon gerekmez.

Rapor sözleşmesi (skill'in üretmesi ZORUNLU)

---
skill: <ad>
run_at: <ISO8601>
duration_sec: <sayı>
status: ok | warn | fail
summary: <tek cümle — Deck'in üstte bastığı başlık>
next_run_notes: |
  - <bir sonraki çalışmaya mesaj>
---
<markdown gövde — "## Executive Summary" ile başlar, tablo ağırlıklı>

Frontmatter eksik/bozuksa gövde yine render edilir (toleranslı decode felsefesi). Format SkillTemplates.reportContract'ta tek yerde tanımlıdır; değişirse SkillReportService.parse ile birlikte güncellenir.

Çalıştırma env'i (her iki modda da aynı)

DECK_SKILL_NAME, DECK_PROJECT_PATH, DECK_PROJECT_NAME, DECK_REPORT_DIR, DECK_REPORT_FILE, DECK_LAST_RUN_AT, DECK_LAST_REPORT, DECK_LAST_STATUS, DECK_LAST_SUMMARY, DECK_LAST_NOTES, DECK_RUN_MODE (manual | scheduled).

DECK_LAST_* alanları önceki rapordan doldurulur — ardışık raporların anlık görüntü değil trend olmasını bunlar sağlar.

İki çalıştırma modu

  • Görünür (kullanıcı "Run"): ClaudeTabLauncher.open(customName: "Skill: <ad>", initialCommand: SkillTemplates.runPrompt, autoRun: true, extraEnv: <env>) — tmux'lu Claude sekmesi, izlenebilir.
  • Sessiz (zamanlanmış / "Run in Background"): LaunchAgentService'in yazdığı zsh script'i → claude -p <runPrompt> --permission-mode acceptEdits. launchd ile Deck tarafı aynı script'i çalıştırır.

İmzalar

  • SkillReportService (enum, sync, arka planda çağrılır): reportsDir(project:skill:), stateFile(project:skill:), newReportFileName(at:), reports(project:skill:) -> [SkillReport] (yeni→eski), latestReport(project:skill:), parse(url:) -> SkillReport?, splitFrontmatter(_:) -> (fields:[String:String]?, body:String), parseDate(_:), runEnvironment(project:skill:mode:at:) -> [String:String], ensureReportsDir(project:skill:), readState(project:skill:) -> SkillRunState?.
  • SkillTemplates (enum): reportContract, environmentContract, creationPrompt(project:skillName:schedule:userNote:), runPrompt(skillName:).
  • LaunchAgentService (enum): label(project:skill:)com.deck.skill.<shortID>.<skill>, plistURL, runnerScriptURL, isInstalled, writeRunnerScript(project:skill:) -> URL (0o755), install(project:skill:schedule:), uninstall(project:skill:), kickstart(project:skill:).
    • launchctl: load/unload DEPRECATED — bootout gui/<uid>/<label> (koşulsuz, önce) sonra bootstrap gui/<uid> <plist>.
    • PATH tuzağı: launchd minimal PATH verir → Shell.userPath snapshot'ı script'e GÖMÜLÜ yazılır; #!/bin/zsh -l'e güvenilmez.
  • SkillScheduleStore (@MainActor ObservableObject): schedules: [UUID:[String:SkillSchedule]], schedule(for:skill:), scheduledCount(for:), setSchedule(_:for:skill:) (JSON + plist'i birlikte günceller — tek yazma yolu), pruneOrphans(project:existing:), removeAll(for:). Dosya: skill-schedules.json, şema {"<projectID>":{"<skill>": SkillSchedule}}, tmp+move.
  • SkillRunner (@MainActor ObservableObject): running: Set<String> ("<projectID>/<skill>"), reports: [UUID:[String:[SkillReport]]], refresh(project:skills:), refresh(project:skill:), runVisible(skill:project:workspace:tabStore:pm:), runSilent(skill:project:) (taze .state.json varsa çakışmayı reddeder), static relative(_:).
  • SkillStore: skills: [UUID:[SkillInfo]], scan(project:scheduleStore:) — tarama sonunda diskte olmayan skill'lerin yetim job'ları temizlenir.
  • DeckPaths: skillSchedulesFile, skillRunnersDir, launchAgentsDir.
  • ProcessManager.startClaude(..., extraEnv: [String:String] = [:]) — env hem PTY'ye hem tmux oturumuna (-e K=V) geçer.

Views — sahiplik ve ana imzalar

  • DeckApp.swift: @main struct DeckApp: App + AppDelegate: NSObject, NSApplicationDelegate (activationPolicy .regular + activate; applicationWillTerminate → processManager.terminateAllSync; hookInstaller arka planda; applicationShouldTerminateAfterLastWindowClosed true). Tüm store'lar burada @StateObject: ProjectStore, ProcessManager, WorkspaceStore, ClaudeTabStore, BrowserManager. Root: RootView(...)@Published selectedProjectID: UUID? bir AppRouter: ObservableObject içinde; nil → HomeView, dolu → ProjectView. Pencere: min 1000x640, titleBar.
  • HomeView.swift: proje grid'i (LazyVGrid, kart: ikon+isim+path+çalışan servis rozeti; tek tık → aç). Yeni/düzenle proje sheet'i (isim, NSOpenPanel dizin, ikon seçimi). Sağ tık: Düzenle/Sil (onaylı).
  • ProjectView.swift: üst bar (← Projeler, proje ikon+adı, "Workspace" butonu Cmd+B, çalışan servis özeti) + CanvasView + workspace overlay: if/opacity ile ZStack — workspace AÇIKKEN de canvas mount kalır. Workspace tüm tab içeriklerini canlı tutar.
  • CanvasView.swift: koyu degrade zemin; ikonlar mutlak pozisyonda (.position), DragGesture ile taşınır (bırakınca store.moveItem). İkon: 72x72 rounded-rect renkli zemin + SF Symbol/emoji + altında isim + durum noktası (servis) / waiting rozeti (claude). Çift tık aksiyonları; servis ikonunda hover'da mini play/stop/restart bar. Sağ tık: Aç / Başlat-Durdur-Yeniden Başlat / KILL PORT / Düzenle / Sil (claude ikonu: Yeni Claude Sekmesi / Geçmişi Sürdür → ClaudeResumeSheet / kapatılmış sekmeler listesi). Boş alan sağ tık: "Yeni Servis / Yeni Komut / Yeni Terminal / Yeni Web". Boş alan çift tık da editör açar.
  • ItemEditorSheet.swift: struct ItemEditorSheet: View — yeni/düzenleme; tür picker en üstte; alanlar türe göre (service: komut+port+autoStart+cwd; oneshot: komut+cwd; shell: cwd; web: url); isim + IconPicker.
  • IconPicker.swift: kürate SF Symbol grid'i + serbest sembol adı + emoji alanı + renk paleti (8-10 hazır renk). IconView(spec: IconSpec, size: CGFloat) yardımcı view'ı da burada (her yerde kullanılır).
  • WorkspaceView.swift: üst sekme çubuğu (sol: ← geri; pill'ler: durum noktası/attention + başlık [claude: customName ?? paneTitle ?? "Claude N"] + x; sağ: "+ Claude" Cmd+T, "+ Terminal" menü, "+ Web"); içerik: TÜM sekmeler ZStack'te, aktif olmayan opacity(0)+allowsHitTesting(false). Claude pill çift tık → rename. Web sekmesi → WebTabView, diğerleri → TerminalHostView.
  • TerminalHostView.swift: struct TerminalHostView: NSViewRepresentable { let key: String; let manager: ProcessManager } — stoker OutputView portu: TerminalContainerView bir kez mount, attach idempotent, eski parent'tan removeFromSuperview, t=0 + t=350ms çift refresh, 80ms debounce'lu resize, makeFirstResponder.
  • WebTabView.swift + BrowserManager: stoker BrowserView portu. @MainActor final class BrowserManager: ObservableObject { func model(forKey key: String, initialURL: String) -> BrowserModel; func clearAll() }; BrowserModel: WKWebView sahibi, developerExtrasEnabled KVC + macOS 13.3+ isInspectable=true İKİSİ de, persistent dataStore, KVO url/canGoBack/canGoForward/isLoading, adres çubuğu, back/forward/reload/stop, mobil UA toggle, Chrome'da aç, createWebViewWith → aynı view'de load, getUserMedia .grant. Container attach deseni.
  • SkillPanelView.swift: Skills paneli — ServicePanelView iskeletinin kardeşi (VStack{bar; Divider; content}, bar 40pt + ultraThinMaterial). Bar: Home, "All Skills", skill pill'leri (durum noktası + ad + saat ikonu), "New Skill", yenile. İçerik: overview grid (kart = ad, açıklama, son çalışma, özet, zamanlama rozeti, durum) veya detay (rapor seçici + Run Again + Schedule + SKILL.md; gövde MarkdownView; altta "Notes for the next run").
  • SkillCreatorSheet.swift: iki mod — .create (ad + ne yapacağı + zamanlama → Claude'a SKILL.md yazdırır) ve .schedule(skill:) (yalnız zamanlama).
  • MarkdownView.swift: bağımlılıksız renderer. MarkdownParser.parse[MarkdownBlock] (heading/paragraph/bulletList/orderedList/table/codeBlock/quote/rule); satır içi biçimlendirme AttributedString(markdown:), başarısızsa düz metne düşer. Tablo el yapımı (projede List/Table kullanılmaz): yatay scroll + zebra + içerik uzunluğuna göre kolon genişliği.
  • ClaudeResumeSheet.swift: proje cwd'sindeki geçmiş oturum listesi (arama, preview + relatif zaman + boyut + kısa uuid), fork toggle, opsiyonel başlangıç mesajı; seçim → onResume(ClaudeResumeOptions).

Akış örnekleri

  • Canvas'ta claude ikonuna çift tık → tabStore.nextNumberWorkspaceTab(kind: .claude, title: "Claude N", tmuxSession: tabID.uuidString, number: N)workspace.addTabpm.startClaude(...)workspace.openWorkspace(true).
  • Servis ikonuna çift tık → servis çalışmıyorsa başlat; her durumda workspace'te o servisin sekmesi yoksa WorkspaceTab(kind: .service, itemID:) ekle + aç (terminal key = item id → aynı PTY view).
  • Web ikonu çift tık → var olan web sekmesi varsa seç, yoksa ekle (key = item id).
  • Proje açılışta: workspace.adoptTmuxSessions(project:) + autoStart servisleri başlat + pm.scanExternalServices.
  • Claude sekmesi x → önce @claude_sid oku, tabStore.recordClosed(number, name, claudeSID, title), sonra closeTab(killTmux: true). Kapatılan sekmeye dönüş = canvas'ta claude sağ-tık → kapatılmışlar → yeni sekmede claude --resume '<sid>'. Uygulama kapanıp açılınca AÇIK sekmeler tmux'tan geri gelir (adoptTmuxSessions).