Skip to content
Merged
Show file tree
Hide file tree
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 Jul 9, 2026
b6361e6
feat: add Server domain model and minimal servers.json store to Nativ…
tukuyomil032 Jul 9, 2026
ebc1db4
feat: add server list sidebar screen and real app window (Task 3-5)
tukuyomil032 Jul 9, 2026
faf010f
feat: add server detail pane (Inspector) to NavigationSplitView
tukuyomil032 Jul 9, 2026
4017b1e
feat: add server process start/stop lifecycle (Task 3-7)
tukuyomil032 Jul 9, 2026
cbbd53b
test: add ServerListViewModel + ServerProcessService integration tests
tukuyomil032 Jul 9, 2026
d064c1b
feat: implement live log-stream screen for native macOS server detail…
tukuyomil032 Jul 9, 2026
3cb657b
fix: drain stderr pipe to prevent server process hang under log spam
tukuyomil032 Jul 9, 2026
4f691a6
feat: implement Floating Console Panel (task 3-9)
tukuyomil032 Jul 9, 2026
ce69e44
feat: add Activity Drawer (task 3-10) for start/stop/crash history
tukuyomil032 Jul 9, 2026
6f625ed
fix: raise waitUntil timeout in ServerListViewModelProcessTests to de…
tukuyomil032 Jul 9, 2026
061030e
fix: log an ActivityEntry when a server start succeeds
tukuyomil032 Jul 9, 2026
12d7805
fix: serve buffered stdout for processes that exit before the first read
tukuyomil032 Jul 9, 2026
20fc9d9
fix: gate stdout pipe salvage on never having been claimed live
tukuyomil032 Jul 9, 2026
a78974a
chore: trim doc comments to satisfy file_length lint after auto-format
tukuyomil032 Jul 9, 2026
c7d8c1c
feat: port security.rs authorization/rate-limit/path-safety/audit log…
tukuyomil032 Jul 9, 2026
70d5c26
docs: disclose intentional stricter-than-Rust interior-dot rejection …
tukuyomil032 Jul 9, 2026
e393a68
fix: surface ServerListViewModel failures via a real alert
tukuyomil032 Jul 9, 2026
4358cc9
ref: split ActivityRow into its own file
tukuyomil032 Jul 9, 2026
060dba4
docs: mark Phase 3-B complete, record swiftui-pro review handover notes
tukuyomil032 Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,5 +181,8 @@ apps/native-macos/.build/
apps/native-macos/.swiftpm/
apps/native-macos/*.xcodeproj

# Instruments trace captures (local performance investigation artifacts)
apps/native-macos/*.trace/

.agents/**
.claude/skills/**
2 changes: 1 addition & 1 deletion apps/native-macos/Sources/App/Main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
struct Main {
static func main() async {
guard let spike = ProcessInfo.processInfo.environment["MCV_SPIKE"] else {
print("MC-Vector Native starting…")
MCVectorApp.main()
return
}

Expand Down
46 changes: 46 additions & 0 deletions apps/native-macos/Sources/Core/ActivityDrawerView.swift
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: [])
}
55 changes: 55 additions & 0 deletions apps/native-macos/Sources/Core/ActivityEntry.swift
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
}
}
83 changes: 83 additions & 0 deletions apps/native-macos/Sources/Core/ActivityRow.swift
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 apps/native-macos/Sources/Core/Domain/AutoBackupScheduleType.swift
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
}
98 changes: 98 additions & 0 deletions apps/native-macos/Sources/Core/Domain/Server.swift
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
}
}
14 changes: 14 additions & 0 deletions apps/native-macos/Sources/Core/Domain/ServerStatus.swift
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
}
67 changes: 67 additions & 0 deletions apps/native-macos/Sources/Core/Domain/ServerStore.swift
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)
}
Comment on lines +51 to +66

Copy link
Copy Markdown
Contributor

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

defaultFileURLtemporaryDirectory フォールバック時にエラーが表面化しないパスがある。

Application Support ディレクトリの解決に失敗した場合、temporaryDirectory にフォールバックします。このフォールバックが成功すると、save()/load() はエラーを投げずに成功しますが、データは揮発性の一時ディレクトリに書き込まれ、macOS によって削除される可能性があります。ドキュメントコメントの「any real failure still surfaces later as a ServerStore.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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/native-macos/Sources/Core/Domain/ServerStore.swift` around lines 51 -
66, The fallback path in ServerStore.defaultFileURL currently hides Application
Support resolution failures by silently using FileManager.temporaryDirectory, so
update this method to surface the situation clearly. Either add a warning/log
when the fallback is chosen or adjust the documentation comment on
ServerStore.defaultFileURL to explicitly state that temporaryDirectory may be
used and data can be volatile. Keep the behavior consistent with save()/load()
expectations and reference defaultFileURL/appDirectory in the fix.

}
Loading
Loading