From 6bb62ebf69b5492d97b69077575c5cf8e6556a43 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 6 Oct 2026 08:55:19 +0100 Subject: [PATCH] added shared support --- PHASE-1-IOS-TEAM.md | 22 ++++++++ .../Shared/Workspaces/TEAM-ARCHITECTURE.md | 32 ++++++++++++ .../Shared/Workspaces/WorkspaceModels.swift | 49 +++++++++++++++++ .../Shared/Workspaces/WorkspaceStore.swift | 52 +++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 PHASE-1-IOS-TEAM.md create mode 100644 SonarDesk/Shared/Workspaces/TEAM-ARCHITECTURE.md create mode 100644 SonarDesk/Shared/Workspaces/WorkspaceModels.swift create mode 100644 SonarDesk/Shared/Workspaces/WorkspaceStore.swift diff --git a/PHASE-1-IOS-TEAM.md b/PHASE-1-IOS-TEAM.md new file mode 100644 index 0000000..2214755 --- /dev/null +++ b/PHASE-1-IOS-TEAM.md @@ -0,0 +1,22 @@ +# SonarDesk iOS + Team — Phase 1 + +Added the non-destructive shared workspace domain foundation. + +## Added +- `WorkspaceKind`: personal/team +- `WorkspaceRole`: owner/admin/developer/viewer +- `SonarWorkspace` +- `WorkspaceMember` +- `WorkspaceStore` +- iCloud Drive persistence for workspace metadata +- Explicit credential boundary: SonarQube tokens stay device-local in Keychain + +## Next in Xcode +1. Add a SonarDesk iOS app target (`net.absdev.SonarDesk.iOS`). +2. Enable the existing iCloud container `iCloud.net.absdev.SonarDesk` plus CloudKit. +3. Share API/models/workspace files with both targets. +4. Add an iOS-specific app entry point and `NavigationSplitView`. +5. Implement Dashboard, Projects and Project Detail. +6. Introduce CloudKit private/shared workspace repository and migrate the workspace JSON records. + +No existing macOS source was modified in Phase 1; the new synchronized-folder files are automatically visible to the current Xcode project. diff --git a/SonarDesk/Shared/Workspaces/TEAM-ARCHITECTURE.md b/SonarDesk/Shared/Workspaces/TEAM-ARCHITECTURE.md new file mode 100644 index 0000000..617e65f --- /dev/null +++ b/SonarDesk/Shared/Workspaces/TEAM-ARCHITECTURE.md @@ -0,0 +1,32 @@ +# SonarDesk shared workspace foundation + +This is the first compatibility-safe step toward macOS + iOS + Team. + +## Storage boundaries + +- iCloud Drive currently remains the persistence mechanism for existing macOS data. +- Workspace metadata is stored in `Data/workspaces.json` so current releases can adopt the model without a destructive migration. +- SonarQube credentials remain in Keychain and are **not** written to iCloud Drive. +- `ThisDeviceOnly` remains the default credential policy. An iPhone therefore authenticates independently. + +## Next migration + +Move workspace/team records to CloudKit private/shared databases: + +- Personal workspace -> private database +- Team workspace -> CKShare/shared database +- Team membership and roles -> shared records +- Reports -> iCloud Drive assets/CloudKit asset metadata + +The existing JSON workspace model is intentionally Codable so it can be migrated to CloudKit records without changing UI-facing domain types. + +## iOS target + +The iOS target should share: + +- Models/Models.swift +- Services/SonarQubeClient.swift +- Services/KeychainService.swift +- Shared/Workspaces/* + +macOS-only UI and exporters stay out of the iOS target. The first iOS screens are Dashboard, Projects and Project Detail. diff --git a/SonarDesk/Shared/Workspaces/WorkspaceModels.swift b/SonarDesk/Shared/Workspaces/WorkspaceModels.swift new file mode 100644 index 0000000..57c9356 --- /dev/null +++ b/SonarDesk/Shared/Workspaces/WorkspaceModels.swift @@ -0,0 +1,49 @@ +import Foundation + +enum WorkspaceKind: String, Codable, CaseIterable, Sendable { + case personal + case team +} + +enum WorkspaceRole: String, Codable, CaseIterable, Sendable { + case owner + case admin + case developer + case viewer +} + +struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable { + var id: UUID + var name: String + var kind: WorkspaceKind + var role: WorkspaceRole + var serverIDs: [UUID] + var createdAt: Date + var updatedAt: Date + + init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole, + serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) { + self.id = id + self.name = name + self.kind = kind + self.role = role + self.serverIDs = serverIDs + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + static let personal = SonarWorkspace( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "Personal", + kind: .personal, + role: .owner + ) +} + +struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable { + var id: UUID + var workspaceID: UUID + var displayName: String + var role: WorkspaceRole + var joinedAt: Date +} diff --git a/SonarDesk/Shared/Workspaces/WorkspaceStore.swift b/SonarDesk/Shared/Workspaces/WorkspaceStore.swift new file mode 100644 index 0000000..dfa0887 --- /dev/null +++ b/SonarDesk/Shared/Workspaces/WorkspaceStore.swift @@ -0,0 +1,52 @@ +import Foundation +import Combine + +@MainActor +final class WorkspaceStore: ObservableObject { + @Published private(set) var workspaces: [SonarWorkspace] = [.personal] + @Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id + + private let driveStore = ICloudDriveStore.shared + private let fileName = "workspaces.json" + + var selectedWorkspace: SonarWorkspace { + workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal + } + + init() { + load() + } + + func load() { + do { + guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else { + persist() + return + } + workspaces = saved + if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) { + selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id + } + } catch { + // Keep Personal usable if iCloud is temporarily unavailable. + workspaces = [.personal] + } + } + + func createTeam(name: String) { + let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { return } + let team = SonarWorkspace(name: clean, kind: .team, role: .owner) + workspaces.append(team) + selectedWorkspaceID = team.id + persist() + } + + func select(_ workspace: SonarWorkspace) { + selectedWorkspaceID = workspace.id + } + + private func persist() { + try? driveStore.write(workspaces, fileName: fileName) + } +}