Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ jobs:
runs-on: macos-15
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Pin Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app

- name: Cache SPM checkouts + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
MacMLXCore/.build
Expand Down Expand Up @@ -84,13 +84,13 @@ jobs:
runs-on: macos-15
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Pin Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app

- name: Cache Xcode DerivedData
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/Library/Developer/Xcode/DerivedData
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,48 @@ Versioning follows [Semantic Versioning](https://semver.org/).

---

## [0.3.7] - 2026-04-18

Maintenance release — four items agreed after the v0.3.6 post-QA pass.

### Added
- **MLX stdout/stderr → Logs tab.** `StdoutCapture.install()` dups
the process's STDOUT/STDERR to a Pipe at launch, tees each line
to both the original fd (terminal visibility preserved) and
`LogManager.shared.debug(category: .system)`. Users reporting
"model is slow" can now see what `mlx-swift-lm` is printing
without attaching a debugger.
- **HF model-update detection.** Downloaded models now get a
`.macmlx-meta.json` sidecar recording the Hub's commit SHA +
`lastModified` at download time. Models tab throttle-checks
(once per 24 h) each sidecar against `/api/models/{id}`. Rows
whose Hub head has advanced get an orange "Update available"
badge. Delete-and-re-download is the update action for now.
- **Shared `~/.mac-mlx/macmlx.pid`.** `PIDFile` moved from the CLI
target into `MacMLXCore`; `Record.owner` enum (`.gui | .cli`)
distinguishes the two. GUI writes the file on `startServer()`,
clears it on `stopServer()`. CLI's `macmlx serve` pre-flights
with `kill(pid, 0)` and refuses to double-bind when a live
record exists, naming the owner in the error so users know
which process to close. `macmlx ps` now shows "Owner: GUI | CLI".

### Changed
- **GitHub Actions pinned to Node.js 24.** `actions/checkout` and
`actions/cache` bumped from `@v4` (Node 20, deprecated) to `@v5`
(Node 24). Silences the deprecation warning that surfaced on the
v0.3.6 release run.

### Notes
- **Full Ollama-style daemon mode** (CLI commands proxied through
the GUI's HTTP API rather than spinning their own engine) remains
a future scope. This release just gets them to stop stepping on
each other at the port-binding layer.
- **Backward compatibility**: pre-v0.3.7 PID files (no `owner` key)
decode as `.cli`. Pre-v0.3.7 downloads without sidecars simply
don't get an update-check until the user re-downloads them once.

---

## [0.3.6] - 2026-04-18

Bug-fix and UX-polish release covering thirteen user-reported issues
Expand Down
62 changes: 61 additions & 1 deletion MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,52 @@ public actor HFDownloader {
}
}

/// Fetch files + commit metadata in a single Hub request.
/// Used by `download(...)` to write the `.macmlx-meta.json`
/// sidecar without a second round-trip.
public func downloadMeta(for modelID: String) async throws -> (
files: [HFRemoteFile],
sha: String?,
lastModified: Date?
) {
let url = baseURL.appending(path: "api/models/\(modelID)")
let data = try await fetchData(from: url)
let envelope = try JSONDecoder.huggingFace.decode(ModelDetailsEnvelope.self, from: data)
let files = envelope.siblings.map { HFRemoteFile(path: $0.rfilename, size: $0.size, lfs: false) }
return (files, envelope.sha, envelope.lastModified)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New downloadMeta duplicates existing files method logic

Low Severity

downloadMeta(for:) is a strict superset of files(for:) — both hit the same /api/models/{id} endpoint, decode the same ModelDetailsEnvelope, and map siblings identically. files(for:) (still called by sizeBytes(for:)) could simply delegate to downloadMeta(for:).files, eliminating the duplicated URL construction, fetch, decode, and map logic. Maintaining two copies risks them drifting apart on future changes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c68e94a. Configure here.


/// Snapshot of how a local download compares to the Hub's current
/// head.
public enum UpdateStatus: Sendable, Equatable {
case upToDate
case updateAvailable(commitSHA: String?, lastModified: Date?)
case unknown
}

public func updateStatus(for meta: DownloadedModelMeta) async -> UpdateStatus {
do {
let url = baseURL.appending(path: "api/models/\(meta.modelID)")
let data = try await fetchData(from: url)
let envelope = try JSONDecoder.huggingFace.decode(
ModelDetailsEnvelope.self, from: data
)
if let localSHA = meta.commitSHA, let remoteSHA = envelope.sha {
return localSHA == remoteSHA
? .upToDate
: .updateAvailable(commitSHA: remoteSHA, lastModified: envelope.lastModified)
}
if let localTime = meta.lastModifiedAtDownload, let remoteTime = envelope.lastModified {
return remoteTime > localTime
? .updateAvailable(commitSHA: envelope.sha, lastModified: remoteTime)
: .upToDate
}
return .unknown
} catch {
return .unknown
}
}

/// Total size of all files in the model repo, in bytes.
///
/// HF's `/api/models/{id}` endpoint omits `size` for LFS-backed
Expand Down Expand Up @@ -461,7 +507,7 @@ public actor HFDownloader {

try FileManager.default.createDirectory(at: modelDir, withIntermediateDirectories: true)

let remoteFiles = try await files(for: modelID)
let (remoteFiles, remoteSHA, remoteLastModified) = try await downloadMeta(for: modelID)
let fileCount = remoteFiles.count
guard fileCount > 0 else { return modelDir }

Expand Down Expand Up @@ -609,6 +655,18 @@ public actor HFDownloader {
))
}

// Persist the sidecar so a later `updateStatus(for:)` call can
// compare the Hub's head against what we snapshotted at download
// time. Best-effort: a write failure shouldn't fail the download
// itself — the user just won't get an "Update available" badge
// for this model until they re-download.
let meta = DownloadedModelMeta(
modelID: modelID,
commitSHA: remoteSHA,
lastModifiedAtDownload: remoteLastModified
)
try? meta.save(to: modelDir)

// All files complete — clear any leftover record (covers the edge
// case where user cancelled file 2, then later resumed and
// completed everything including a pending record on file 2).
Expand Down Expand Up @@ -968,6 +1026,8 @@ internal final class DownloadSessionRouter: NSObject,

private struct ModelDetailsEnvelope: Decodable {
let siblings: [SiblingEntry]
let sha: String?
let lastModified: Date?

struct SiblingEntry: Decodable {
let rfilename: String
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,67 @@
import Foundation

/// Coordinates between `macmlx serve` and `macmlx stop`/`macmlx ps` via a
/// JSON file at `~/.mac-mlx/macmlx.pid`.
/// Coordinates server discovery between the GUI (`macMLX.app`) and
/// CLI (`macmlx`) — both write to `~/.mac-mlx/macmlx.pid` so either
/// side can tell whether the other is already serving on port 8000.
///
/// The PID file stores the process ID, port, optional loaded model ID, and
/// start time. It is written by `serve` on startup and removed on clean exit.
/// `Record.owner` distinguishes the two: GUI sets `.gui`, CLI sets
/// `.cli`. `macmlx ps` renders the owner; `macmlx serve` refuses to
/// start when a record whose PID is still alive is found.
///
/// NOTE: Race conditions between concurrent `serve` invocations are not
/// handled in v0.1 — the last writer wins. Document and skip.
/// Backward compat: pre-v0.3.7 PID files lacked the `owner` key and
/// were always written by the CLI. Decoding defaults missing `owner`
/// to `.cli` so upgrading in place doesn't require manually deleting
/// the pid file.
public enum PIDFile {
/// Persistent record stored in the PID file.
public struct Record: Codable, Sendable {
/// Which process wrote this record — used by `macmlx serve`
/// to name the conflicting owner in its error message, and
/// by `macmlx ps` to show the user which side is serving.
public enum Owner: String, Codable, Sendable {
case gui
case cli
}

public var pid: Int32
public var port: Int
public var modelID: String?
public var startedAt: Date
public var owner: Owner

public init(pid: Int32, port: Int, modelID: String?, startedAt: Date) {
public init(
pid: Int32,
port: Int,
modelID: String?,
startedAt: Date,
owner: Owner
) {
self.pid = pid
self.port = port
self.modelID = modelID
self.startedAt = startedAt
self.owner = owner
}

private enum CodingKeys: String, CodingKey {
case pid, port, modelID, startedAt, owner
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decode(Int32.self, forKey: .pid)
self.port = try c.decode(Int.self, forKey: .port)
self.modelID = try c.decodeIfPresent(String.self, forKey: .modelID)
self.startedAt = try c.decode(Date.self, forKey: .startedAt)
self.owner = (try c.decodeIfPresent(Owner.self, forKey: .owner)) ?? .cli
}
}

/// URL of the PID file: `~/.mac-mlx/macmlx.pid`.
/// URL of the PID file. Uses `DataRoot.macMLX` so the GUI (which
/// runs without sandbox since v0.3.6) and CLI both resolve to the
/// same real-home `~/.mac-mlx/` path.
public static var url: URL {
FileManager.default.homeDirectoryForCurrentUser
.appending(path: ".mac-mlx/macmlx.pid")
DataRoot.macMLX.appending(path: "macmlx.pid", directoryHint: .notDirectory)
}

/// Write `record` to the PID file atomically.
Expand Down
53 changes: 53 additions & 0 deletions MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

/// Metadata sidecar stored next to a downloaded model at
/// `<modelDir>/.macmlx-meta.json`. Persisted on first successful
/// download so we can later detect when the Hub repo has advanced
/// past this snapshot.
public struct DownloadedModelMeta: Codable, Sendable {
/// Full HF model ID (e.g. `mlx-community/Qwen3-8B-4bit`).
public let modelID: String
/// Commit SHA of the `main` branch at download time, if HF
/// exposed it. Nil for older downloads predating this field.
public let commitSHA: String?
/// `lastModified` timestamp reported by `/api/models/{id}` at
/// download time.
public let lastModifiedAtDownload: Date?
/// Wall-clock time of the download event (may lag behind
/// `lastModifiedAtDownload` by minutes).
public let downloadedAt: Date

public init(
modelID: String,
commitSHA: String?,
lastModifiedAtDownload: Date?,
downloadedAt: Date = Date()
) {
self.modelID = modelID
self.commitSHA = commitSHA
self.lastModifiedAtDownload = lastModifiedAtDownload
self.downloadedAt = downloadedAt
}

public static let filename = ".macmlx-meta.json"

public static func url(inside modelDir: URL) -> URL {
modelDir.appending(path: filename, directoryHint: .notDirectory)
}

public static func load(from modelDir: URL) -> DownloadedModelMeta? {
let fileURL = url(inside: modelDir)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(DownloadedModelMeta.self, from: data)
}

public func save(to modelDir: URL) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted]
let data = try encoder.encode(self)
try data.write(to: Self.url(inside: modelDir), options: .atomic)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import XCTest
@testable import MacMLXCore

final class DownloadedModelMetaTests: XCTestCase {
private func tmpDir() -> URL {
let dir = FileManager.default.temporaryDirectory
.appending(path: "macmlx-meta-test-\(UUID().uuidString)", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}

func testRoundtrip() throws {
let dir = tmpDir()
let meta = DownloadedModelMeta(
modelID: "mlx-community/Qwen3-8B-4bit",
commitSHA: "abc123",
lastModifiedAtDownload: Date(timeIntervalSince1970: 1_700_000_000)
)
try meta.save(to: dir)
let loaded = DownloadedModelMeta.load(from: dir)
XCTAssertEqual(loaded?.modelID, "mlx-community/Qwen3-8B-4bit")
XCTAssertEqual(loaded?.commitSHA, "abc123")
XCTAssertEqual(
loaded?.lastModifiedAtDownload?.timeIntervalSince1970 ?? 0,
1_700_000_000,
accuracy: 1
)
}

func testMissingSidecar() {
XCTAssertNil(DownloadedModelMeta.load(from: tmpDir()))
}

func testCorruptSidecar() throws {
let dir = tmpDir()
try "not json".data(using: .utf8)!.write(
to: DownloadedModelMeta.url(inside: dir),
options: .atomic
)
XCTAssertNil(DownloadedModelMeta.load(from: dir))
}
}
Loading