-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Native macOS Phase 3-B — server screens, process lifecycle, security port #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
49bab05
docs: sync phase matrix with Phase 2/3-A completion, ignore Instrumen…
tukuyomil032 b6361e6
feat: add Server domain model and minimal servers.json store to Nativ…
tukuyomil032 ebc1db4
feat: add server list sidebar screen and real app window (Task 3-5)
tukuyomil032 faf010f
feat: add server detail pane (Inspector) to NavigationSplitView
tukuyomil032 4017b1e
feat: add server process start/stop lifecycle (Task 3-7)
tukuyomil032 cbbd53b
test: add ServerListViewModel + ServerProcessService integration tests
tukuyomil032 d064c1b
feat: implement live log-stream screen for native macOS server detail…
tukuyomil032 3cb657b
fix: drain stderr pipe to prevent server process hang under log spam
tukuyomil032 4f691a6
feat: implement Floating Console Panel (task 3-9)
tukuyomil032 ce69e44
feat: add Activity Drawer (task 3-10) for start/stop/crash history
tukuyomil032 6f625ed
fix: raise waitUntil timeout in ServerListViewModelProcessTests to de…
tukuyomil032 061030e
fix: log an ActivityEntry when a server start succeeds
tukuyomil032 12d7805
fix: serve buffered stdout for processes that exit before the first read
tukuyomil032 20fc9d9
fix: gate stdout pipe salvage on never having been claimed live
tukuyomil032 a78974a
chore: trim doc comments to satisfy file_length lint after auto-format
tukuyomil032 c7d8c1c
feat: port security.rs authorization/rate-limit/path-safety/audit log…
tukuyomil032 70d5c26
docs: disclose intentional stricter-than-Rust interior-dot rejection …
tukuyomil032 e393a68
fix: surface ServerListViewModel failures via a real alert
tukuyomil032 4358cc9
ref: split ActivityRow into its own file
tukuyomil032 060dba4
docs: mark Phase 3-B complete, record swiftui-pro review handover notes
tukuyomil032 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import SwiftUI | ||
|
|
||
| /// Content of the Activity Drawer -- a global (all-servers) view of | ||
| /// `ServerListViewModel.activityLog`, presented via `.inspector` from | ||
| /// `RootView`. | ||
| /// | ||
| /// Deliberately a plain `List`/`ForEach`, not the `ScrollView` + `LazyVStack` | ||
| /// pattern task 3-8's console output needed: that pattern exists for a | ||
| /// high-frequency, potentially-thousands-of-lines stream where `List`'s | ||
| /// diffing overhead matters. `activityLog` is bounded (see | ||
| /// `ServerListViewModel.activityLogCap`) and updates far less often (one | ||
| /// entry per process start/stop/crash, not per log line), so `List`'s | ||
| /// automatic diffing is the right, standard, simple tool here -- and it | ||
| /// (like `.inspector` and `.toolbar`) gets Liquid Glass styling automatically | ||
| /// just by being used, with no manual `.glassEffect` needed. | ||
| struct ActivityDrawerView: View { | ||
| let entries: [ActivityEntry] | ||
|
|
||
| var body: some View { | ||
| Group { | ||
| if self.entries.isEmpty { | ||
| ContentUnavailableView( | ||
| "No Activity Yet", | ||
| systemImage: "clock.arrow.circlepath", | ||
| description: Text("Server start, stop, and crash events will appear here."), | ||
| ) | ||
| } else { | ||
| List(self.entries) { entry in | ||
| ActivityRow(entry: entry) | ||
| } | ||
| } | ||
| } | ||
| .navigationTitle("Activity") | ||
| } | ||
| } | ||
|
|
||
| #Preview { | ||
| ActivityDrawerView(entries: [ | ||
| ActivityEntry(serverId: "srv-1", serverName: "Survival", kind: .serverStatusChange(.offline)), | ||
| ActivityEntry(serverId: "srv-2", serverName: "Creative", kind: .serverStatusChange(.crashed)) | ||
| ]) | ||
| } | ||
|
|
||
| #Preview("Empty") { | ||
| ActivityDrawerView(entries: []) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import Foundation | ||
|
|
||
| /// A single entry in `ServerListViewModel.activityLog` -- a lightweight, | ||
| /// display-only record of something that happened to a tracked server. | ||
| /// | ||
| /// Unlike `Server` (which mirrors a TypeScript domain type over a JSON wire | ||
| /// format, and so constrains dates to ISO8601 `String`s -- see | ||
| /// `Server.createdDate`), `ActivityEntry` has no cross-language contract and | ||
| /// is never persisted (see `ServerListViewModel.activityLog`'s doc comment | ||
| /// for why): a plain `Date` is the right, simple choice for `timestamp`. | ||
| public struct ActivityEntry: Sendable, Identifiable, Equatable { | ||
| /// What kind of thing happened. Currently only tracks process status | ||
| /// changes -- both those observed via `ServerProcessService.events` | ||
| /// (`.offline`/`.crashed`, applied by `ServerListViewModel.apply(_:)`) | ||
| /// and the synchronous `.online` transition `ServerListViewModel | ||
| /// .startSelectedServer()` logs directly on a successful start. Both | ||
| /// paths construct entries through `ServerListViewModel | ||
| /// .appendActivity(forServerId:status:)`. | ||
| /// | ||
| /// Deliberately an enum rather than folding `ServerStatus` directly into | ||
| /// `ActivityEntry` -- this app has no backup feature/service implemented | ||
| /// anywhere yet (`Server.autoBackup*` fields exist on the domain model, | ||
| /// but nothing schedules or runs a backup), so there is nothing to log | ||
| /// for it today. `Kind` exists so a future case such as | ||
| /// `.backupCompleted` can be added later without reshaping | ||
| /// `ActivityEntry` itself or this file's callers. | ||
| public enum Kind: Sendable, Equatable { | ||
| case serverStatusChange(ServerStatus) | ||
| } | ||
|
|
||
| public let id: UUID | ||
| public let serverId: String | ||
| /// Resolved from `ServerListViewModel.servers` at append time (not | ||
| /// looked up lazily when the drawer renders) so a server later removed | ||
| /// from `servers` can't leave this entry with a dangling reference -- | ||
| /// the drawer can always render a name for a historical entry, even for | ||
| /// a server that no longer exists. | ||
| public let serverName: String | ||
| public let kind: Kind | ||
| public let timestamp: Date | ||
|
|
||
| public init( | ||
| id: UUID = UUID(), | ||
| serverId: String, | ||
| serverName: String, | ||
| kind: Kind, | ||
| timestamp: Date = Date(), | ||
| ) { | ||
| self.id = id | ||
| self.serverId = serverId | ||
| self.serverName = serverName | ||
| self.kind = kind | ||
| self.timestamp = timestamp | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import SwiftUI | ||
|
|
||
| /// A single row in `ActivityDrawerView`'s `List`, rendering one | ||
| /// `ActivityEntry`. | ||
| /// | ||
| /// Split into its own file (task 3-12 code-review fix) -- previously | ||
| /// declared alongside `ActivityDrawerView` in `ActivityDrawerView.swift`, | ||
| /// violating this codebase's one-type-per-file convention that every other | ||
| /// View in this phase already follows. Left at the default `internal` | ||
| /// visibility (not `private`, since `private` would no longer be usable from | ||
| /// `ActivityDrawerView.swift` once split out; not `public`, since nothing | ||
| /// outside this module constructs a row directly -- `ActivityDrawerView` is | ||
| /// the sole caller) -- an implementation detail of the drawer, not part of | ||
| /// this package's public API. | ||
| struct ActivityRow: View { | ||
| let entry: ActivityEntry | ||
|
|
||
| var body: some View { | ||
| HStack(spacing: 10) { | ||
| Image(systemName: self.systemImage) | ||
| .foregroundStyle(self.tint) | ||
| .frame(width: 20) | ||
|
|
||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text(self.entry.serverName) | ||
| .font(.body) | ||
| Text(self.statusLabel) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
|
|
||
| Spacer() | ||
|
|
||
| // Standard SwiftUI relative-date `Text` -- auto-updates ("2m | ||
| // ago" -> "3m ago") without any manual timer/formatter code. | ||
| Text(self.entry.timestamp, style: .relative) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| .monospacedDigit() | ||
| } | ||
| .accessibilityElement(children: .combine) | ||
| } | ||
|
|
||
| private var status: ServerStatus { | ||
| switch self.entry.kind { | ||
| case let .serverStatusChange(status): status | ||
| } | ||
| } | ||
|
|
||
| private var statusLabel: String { | ||
| self.status.rawValue.capitalized | ||
| } | ||
|
|
||
| private var systemImage: String { | ||
| switch self.status { | ||
| case .online: "play.circle.fill" | ||
| case .offline: "stop.circle" | ||
| case .starting, .restarting: "arrow.triangle.2.circlepath.circle" | ||
| case .stopping: "stop.circle.fill" | ||
| case .crashed: "exclamationmark.triangle.fill" | ||
| } | ||
| } | ||
|
|
||
| private var tint: Color { | ||
| switch self.status { | ||
| case .online: .green | ||
| case .offline: .secondary | ||
| case .starting, .restarting, .stopping: .orange | ||
| case .crashed: .red | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #Preview { | ||
| List { | ||
| ActivityRow( | ||
| entry: ActivityEntry(serverId: "srv-1", serverName: "Survival", kind: .serverStatusChange(.online)), | ||
| ) | ||
| ActivityRow( | ||
| entry: ActivityEntry(serverId: "srv-2", serverName: "Creative", kind: .serverStatusChange(.crashed)), | ||
| ) | ||
| } | ||
| } |
11 changes: 11 additions & 0 deletions
11
apps/native-macos/Sources/Core/Domain/AutoBackupScheduleType.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| /// How an automatic backup schedule is expressed for a server. | ||
| /// | ||
| /// Mirrors the TypeScript `autoBackupScheduleType` union | ||
| /// (`'interval' | 'daily' | 'weekly'`) shared by `MinecraftServer` and | ||
| /// `ServerTemplate` in `src/renderer/shared/server declaration.ts` / | ||
| /// `src/lib/server-commands.ts`. | ||
| public enum AutoBackupScheduleType: String, Codable, Sendable, Equatable, CaseIterable { | ||
| case interval | ||
| case daily | ||
| case weekly | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /// A running (or runnable) Minecraft server instance. | ||
| /// | ||
| /// Mirrors the TypeScript `MinecraftServer` interface in | ||
| /// `src/renderer/shared/server declaration.ts` on the Tauri Classic side. | ||
| /// JSON keys match the TS property names exactly (camelCase, no | ||
| /// snake_case conversion), so `Codable` synthesis lines up automatically | ||
| /// without custom `CodingKeys`. | ||
| /// | ||
| /// `createdDate` is kept as a plain ISO-8601 `String` rather than decoded | ||
| /// into `Date` — the JSON on disk stores it as a string, and introducing a | ||
| /// custom `JSONDecoder.dateDecodingStrategy` is out of scope for this task. | ||
| public struct Server: Codable, Sendable, Equatable, Identifiable { | ||
| public var id: String | ||
| public var name: String | ||
| public var profileName: String? | ||
| public var groupName: String? | ||
| public var version: String | ||
| public var software: String | ||
| public var port: Int | ||
| public var memory: Int | ||
| public var path: String | ||
| public var status: ServerStatus | ||
| public var javaPath: String? | ||
| public var autoRestartOnCrash: Bool? | ||
| public var maxAutoRestarts: Int? | ||
| public var autoRestartDelaySec: Int? | ||
| public var autoBackupEnabled: Bool? | ||
| public var autoBackupIntervalMin: Int? | ||
| public var autoBackupScheduleType: AutoBackupScheduleType? | ||
| public var autoBackupTime: String? | ||
| public var autoBackupWeekday: Int? | ||
| public var autoBackupRetainCount: Int? | ||
| public var autoBackupRetainDays: Int? | ||
| public var createdDate: String? | ||
| public var jvmArgs: String? | ||
| public var notifyOnCrash: Bool? | ||
| public var notifyOnStart: Bool? | ||
| public var notifyOnHighCpu: Bool? | ||
| public var notifyHighCpuThreshold: Int? | ||
|
|
||
| public init( | ||
| id: String, | ||
| name: String, | ||
| profileName: String? = nil, | ||
| groupName: String? = nil, | ||
| version: String, | ||
| software: String, | ||
| port: Int, | ||
| memory: Int, | ||
| path: String, | ||
| status: ServerStatus, | ||
| javaPath: String? = nil, | ||
| autoRestartOnCrash: Bool? = nil, | ||
| maxAutoRestarts: Int? = nil, | ||
| autoRestartDelaySec: Int? = nil, | ||
| autoBackupEnabled: Bool? = nil, | ||
| autoBackupIntervalMin: Int? = nil, | ||
| autoBackupScheduleType: AutoBackupScheduleType? = nil, | ||
| autoBackupTime: String? = nil, | ||
| autoBackupWeekday: Int? = nil, | ||
| autoBackupRetainCount: Int? = nil, | ||
| autoBackupRetainDays: Int? = nil, | ||
| createdDate: String? = nil, | ||
| jvmArgs: String? = nil, | ||
| notifyOnCrash: Bool? = nil, | ||
| notifyOnStart: Bool? = nil, | ||
| notifyOnHighCpu: Bool? = nil, | ||
| notifyHighCpuThreshold: Int? = nil | ||
| ) { | ||
| self.id = id | ||
| self.name = name | ||
| self.profileName = profileName | ||
| self.groupName = groupName | ||
| self.version = version | ||
| self.software = software | ||
| self.port = port | ||
| self.memory = memory | ||
| self.path = path | ||
| self.status = status | ||
| self.javaPath = javaPath | ||
| self.autoRestartOnCrash = autoRestartOnCrash | ||
| self.maxAutoRestarts = maxAutoRestarts | ||
| self.autoRestartDelaySec = autoRestartDelaySec | ||
| self.autoBackupEnabled = autoBackupEnabled | ||
| self.autoBackupIntervalMin = autoBackupIntervalMin | ||
| self.autoBackupScheduleType = autoBackupScheduleType | ||
| self.autoBackupTime = autoBackupTime | ||
| self.autoBackupWeekday = autoBackupWeekday | ||
| self.autoBackupRetainCount = autoBackupRetainCount | ||
| self.autoBackupRetainDays = autoBackupRetainDays | ||
| self.createdDate = createdDate | ||
| self.jvmArgs = jvmArgs | ||
| self.notifyOnCrash = notifyOnCrash | ||
| self.notifyOnStart = notifyOnStart | ||
| self.notifyOnHighCpu = notifyOnHighCpu | ||
| self.notifyHighCpuThreshold = notifyHighCpuThreshold | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| /// Lifecycle state of a Minecraft server instance. | ||
| /// | ||
| /// Mirrors the TypeScript `ServerStatus` union in | ||
| /// `src/renderer/shared/server declaration.ts` on the Tauri Classic side. | ||
| /// The raw string values are the JSON wire format and must stay in sync | ||
| /// with that union. | ||
| public enum ServerStatus: String, Codable, Sendable, Equatable, CaseIterable { | ||
| case online | ||
| case offline | ||
| case starting | ||
| case stopping | ||
| case restarting | ||
| case crashed | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import Foundation | ||
|
|
||
| /// Minimal, independent reader/writer for the Native app's own | ||
| /// `servers.json`. | ||
| /// | ||
| /// This does not interoperate with the Tauri Classic app's on-disk store — | ||
| /// the Native app manages its own file, whose location is injected as a | ||
| /// `URL` so tests can point it at a temp directory. This is intentionally | ||
| /// thin: just enough to prove `Server`/`ServerTemplate` round-trip through | ||
| /// JSON and that a real file can be read and written. CRUD/UI wiring is | ||
| /// out of scope for this task. | ||
| public actor ServerStore { | ||
| private let fileURL: URL | ||
|
|
||
| public init(fileURL: URL) { | ||
| self.fileURL = fileURL | ||
| } | ||
|
|
||
| public func load() throws -> ServersFile { | ||
| let data = try Data(contentsOf: self.fileURL) | ||
| return try JSONDecoder().decode(ServersFile.self, from: data) | ||
| } | ||
|
|
||
| public func save(_ file: ServersFile) throws { | ||
| let encoder = JSONEncoder() | ||
| encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | ||
| let data = try encoder.encode(file) | ||
| try data.write(to: self.fileURL, options: .atomic) | ||
| } | ||
| } | ||
|
|
||
| extension ServerStore { | ||
| /// Subdirectory name under Application Support that holds this app's | ||
| /// own on-disk data. Distinct from the Tauri Classic app's storage. | ||
| private static let applicationSupportSubdirectoryName = "MC-Vector Native" | ||
|
|
||
| /// Resolves the production `servers.json` location under the current | ||
| /// user's Application Support directory, creating the parent directory | ||
| /// first if it doesn't exist yet. | ||
| /// | ||
| /// Non-throwing by design so it can back a `View`'s `@State` default | ||
| /// initializer directly: directory resolution/creation failures here | ||
| /// are exceedingly rare (a missing/unwritable Application Support | ||
| /// directory would already be breaking most of macOS), and any real | ||
| /// failure still surfaces later as a `ServerStore.load()`/`save()` | ||
| /// error, which callers already handle. | ||
| /// | ||
| /// Tests must not call this -- it touches the real Application Support | ||
| /// directory on the machine running the tests. Point a `ServerStore` at | ||
| /// a temp file instead (see `ServerStoreTests`). | ||
| public static func defaultFileURL(fileManager: FileManager = .default) -> URL { | ||
| let supportDirectory = (try? fileManager.url( | ||
| for: .applicationSupportDirectory, | ||
| in: .userDomainMask, | ||
| appropriateFor: nil, | ||
| create: true, | ||
| )) ?? fileManager.temporaryDirectory | ||
|
|
||
| let appDirectory = supportDirectory.appendingPathComponent( | ||
| self.applicationSupportSubdirectoryName, | ||
| isDirectory: true, | ||
| ) | ||
| try? fileManager.createDirectory(at: appDirectory, withIntermediateDirectories: true) | ||
|
|
||
| return appDirectory.appendingPathComponent("servers.json", isDirectory: false) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
defaultFileURLのtemporaryDirectoryフォールバック時にエラーが表面化しないパスがある。Application Support ディレクトリの解決に失敗した場合、
temporaryDirectoryにフォールバックします。このフォールバックが成功すると、save()/load()はエラーを投げずに成功しますが、データは揮発性の一時ディレクトリに書き込まれ、macOS によって削除される可能性があります。ドキュメントコメントの「any real failure still surfaces later as aServerStore.load()/save()error」という記述は、このパスでは成立しません。フォールバック時に警告ログを出力するか、ドキュメントコメントを修正してこの挙動を明記することを推奨します。
🛡️ 提案する修正: フォールバック時の警告ログ追加
public static func defaultFileURL(fileManager: FileManager = .default) -> URL { - let supportDirectory = (try? fileManager.url( + let supportDirectory: URL + if let resolved = try? fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true, - )) ?? fileManager.temporaryDirectory + ) { + supportDirectory = resolved + } else { + // Application Support の解決に失敗した場合は一時ディレクトリにフォールバックする。 + // データが揮発性の場所に保存されるため、実環境では極めて稀だが注意が必要。 + print("Warning: Failed to resolve Application Support directory; falling back to temporary directory.") + supportDirectory = fileManager.temporaryDirectory + }🤖 Prompt for AI Agents