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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ A 3D LUT is a table with a color at every lattice point. The photo's colors are
- **Adjust** — nine sliders after develop, before the LUT: exposure, brightness, contrast, saturation, highlights, shadows, temperature, tint, vibrance
- **Filmstrip** when a source folder is open. <kbd>←</kbd> <kbd>→</kbd> or <kbd>[</kbd> <kbd>]</kbd> step through; the current look stays on. <kbd>⌘R</kbd> rescans.
- **Window** — Liquid Glass toolbar, customizable (View ▸ Customize Toolbar…). The file and LUT names sit in the title. <kbd>⌘,</kbd> sets launch defaults: side-by-side, source browser, export format.
- **Updates** — once a day the packaged app looks at this repo's [releases](https://github.com/tsvb/lutzy/releases) and offers anything newer. LUTzy ▸ Check for Updates… asks now. Install downloads the DMG, verifies the app inside is signed by the same Developer ID team as the running copy, swaps it in place, and relaunches. Off switch and skip-this-version in Settings.
- **Export** — 16-bit TIFF, JPEG (quality 0.95), or PNG, always full resolution, named `{photo}_{LUT}.ext` (spaces in the LUT name become underscores). <kbd>⌘⇧E</kbd> Export All writes the whole look — develop, adjustments, LUT, intensity — and counts failures instead of aborting.

## Derive LUT from JPG
Expand Down Expand Up @@ -69,7 +70,7 @@ The RAW is developed with the same default `CIRAWFilter` settings the rest of th
| <kbd>⌘D</kbd> | derive |
| <kbd>⌘S</kbd> | export |
| <kbd>⌘⇧E</kbd> | export all |
| <kbd>⌘,</kbd> | settings — launch defaults and the sidebar's collapsed folders |
| <kbd>⌘,</kbd> | settings — launch defaults, update checks, and the sidebar's collapsed folders |

Letter keys go through SwiftUI's `.onKeyPress` on the split view; the preview canvas is focusable and holds focus by default so the handler always has a focused descendant. <kbd>⌘</kbd> shortcuts go through the menu bar.

Expand Down Expand Up @@ -116,6 +117,8 @@ scripts/release-dmg.sh 0.1.1

Does what an Xcode archive would: universal release build, a hand-written `LUTzy.app` with an `Info.plist` and the icon, signed with Developer ID and the hardened runtime (no sandbox yet), notarized and stapled, then packed into a DMG that is notarized and stapled again. Output lands in `build/release/`. Needs `create-dmg` (`brew install create-dmg`) and a notarytool keychain profile.

Publish the DMG as a GitHub release tagged `vX.Y.Z`, the same triple passed to the script. The in-app updater reads `releases/latest` and picks the first `.dmg` asset, so a release without one is offered as a page to open rather than an install.

The icon is drawn by `scripts/render-icon.swift`: the RGB cube a `.cube` file indexes, seen from its green corner, rendered into every slot of the appiconset. Run it again rather than editing the PNGs.

## License
Expand Down
6 changes: 6 additions & 0 deletions Sources/LUTzyKit/Models/AppPreference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ enum AppPreference {
static let showSourceBrowserOnRestore = "lutzy.showSourceBrowserOnRestore"
/// `ExportFormat.rawValue` to start with. Default JPEG.
static let defaultExportFormat = "lutzy.defaultExportFormat"
/// Whether the launch-time release check runs. Default `true`. Read live by `UpdateCoordinator`.
static let automaticUpdateChecks = "lutzy.automaticUpdateChecks"
/// `Date` of the last completed release check, automatic or manual.
static let lastUpdateCheck = "lutzy.lastUpdateCheck"
/// A version string the user chose to skip; the automatic check stays quiet about it.
static let skippedUpdateVersion = "lutzy.skippedUpdateVersion"
}

extension UserDefaults {
Expand Down
46 changes: 46 additions & 0 deletions Sources/LUTzyKit/Models/AppVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

/// A `major.minor.patch` version, as found in `CFBundleShortVersionString` and in a release tag.
///
/// Accepts a leading `v` because GitHub tags carry one (`v0.1.0`) and Info.plist does not (`0.1.0`).
/// Anything after the third component, or a pre-release suffix, is rejected rather than guessed at:
/// the release script only ever produces plain triples, so a tag that does not parse is a tag the
/// updater should ignore, not one it should half-understand.
struct AppVersion: Sendable, Equatable, Comparable, CustomStringConvertible {
let major: Int
let minor: Int
let patch: Int

init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}

init?(_ string: String) {
var text = Substring(string.trimmingCharacters(in: .whitespaces))
if text.first == "v" || text.first == "V" { text = text.dropFirst() }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard (1...3).contains(parts.count) else { return nil }
var numbers: [Int] = []
for part in parts {
guard !part.isEmpty, part.allSatisfy(\.isNumber), let n = Int(part) else { return nil }
numbers.append(n)
}
while numbers.count < 3 { numbers.append(0) }
self.init(numbers[0], numbers[1], numbers[2])
}

static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
(lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch)
}

var description: String { "\(major).\(minor).\(patch)" }

/// The version of the running app, or `nil` for a bare SwiftPM executable, which has no
/// Info.plist. Updates are a property of the packaged app; `swift run` has nothing to update.
static var current: AppVersion? {
guard let string = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil }
return AppVersion(string)
}
}
91 changes: 91 additions & 0 deletions Sources/LUTzyKit/Models/ReleaseFeed.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation

/// One published release, reduced to what the updater needs.
struct Release: Sendable, Equatable {
let version: AppVersion
let title: String
/// The release notes, as GitHub-flavoured Markdown.
let notes: String
/// The release page, for a build that cannot install itself.
let pageURL: URL
/// The disk image to install from, if the release carries one.
let diskImageURL: URL?
let diskImageSize: Int?
}

/// Where releases come from: the GitHub Releases API for this repository.
///
/// `releases/latest` is the newest non-draft, non-prerelease release, which is exactly the set
/// `scripts/release-dmg.sh` output is published to. The DMG asset is found by extension, so the
/// asset name can change without a code change; a release with no DMG is still reported, and the
/// sheet offers the release page instead of an install.
enum ReleaseFeed {

static let repository = "tsvb/lutzy"

static func latestURL(repository: String = repository) -> URL {
URL(string: "https://api.github.com/repos/\(repository)/releases/latest")!
}

enum FeedError: LocalizedError, Equatable {
case badStatus(Int)
case unusableTag(String)

var errorDescription: String? {
switch self {
case .badStatus(let code): return "GitHub answered with status \(code)."
case .unusableTag(let tag): return "The latest release is tagged \"\(tag)\", which is not a version."
}
}
}

/// Fetch and decode the latest release. Unauthenticated: sixty requests an hour is plenty for
/// one check a day plus a few manual ones.
static func fetchLatest(
repository: String = repository,
session: URLSession = .shared
) async throws -> Release {
var request = URLRequest(url: latestURL(repository: repository))
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("LUTzy/\(AppVersion.current?.description ?? "dev")", forHTTPHeaderField: "User-Agent")
request.timeoutInterval = 15
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw FeedError.badStatus(http.statusCode)
}
return try parse(data)
}

// MARK: - Decoding

private struct Payload: Decodable {
struct Asset: Decodable {
let name: String
let browser_download_url: URL
let size: Int?
}
let tag_name: String
let name: String?
let body: String?
let html_url: URL
let assets: [Asset]?
}

/// Decode a `releases/latest` body. Internal so the decoder can be tested on a fixture without
/// a network.
static func parse(_ data: Data) throws -> Release {
let payload = try JSONDecoder().decode(Payload.self, from: data)
guard let version = AppVersion(payload.tag_name) else {
throw FeedError.unusableTag(payload.tag_name)
}
let dmg = payload.assets?.first { $0.name.lowercased().hasSuffix(".dmg") }
return Release(
version: version,
title: payload.name?.isEmpty == false ? payload.name! : "LUTzy \(version)",
notes: payload.body ?? "",
pageURL: payload.html_url,
diskImageURL: dmg?.browser_download_url,
diskImageSize: dmg?.size
)
}
}
Loading
Loading