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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import Foundation
/// from the debug socket, so a behavior check goes through the real owners.
/// `{call: "inbox"}`, `{call: "snapshot", conversation, tail?}`,
/// `{call: "submit", op: {kind, ...}}`; see `op(_:)` for the kinds;
/// `{call: "chief_switch", choice}` (DebugHome+Hosted.swift); `{call: "rename_chief", conversation, name}`.
/// `{call: "chief_switch", choice}` (DebugHome+Hosted.swift); `{call: "rename_chief", conversation, name}`;
/// `{call: "edit_picture", conversation, path, zoom, save}`.
enum DebugHomeAPI {
static func handle(_ params: [String: JSONValue], services: AppServices) async -> JSONValue {
let (router, me) = await MainActor.run { (services.home.homeRouter, services.home.homeSource.me.id) }
Expand Down Expand Up @@ -37,6 +38,12 @@ enum DebugHomeAPI {
return failure("no Chief conversation")
}
return await renameChief(id, to: name, services: services)
case "edit_picture":
// A picture picked or dropped on the Chief card (issue 27915): Move and Scale opens; `save` presses Save.
guard let path = params["path"]?.stringValue else { return failure("path required") }
let zoom = params["zoom"]?.doubleValue.map { CGFloat($0) }
let save = params["save"]?.boolValue ?? false
return await editPicture(params["conversation"]?.stringValue, path: path, zoom: zoom, save: save, services: services)
case "chief_names":
// Every surface's name for `conversation`'s Chief (else the default Chief's), issue 26393.
return await MainActor.run { chiefNames(params["conversation"]?.stringValue, services: services) }
Expand All @@ -48,7 +55,7 @@ enum DebugHomeAPI {
guard let id = params["conversation"]?.stringValue else { return failure("conversation required") }
return await MainActor.run { DebugHomeWatch.store(services.home.homeStore, conversation: ConversationID(id)) }
default:
return failure("call must be inbox, snapshot, submit, chief_switch, rename_chief, watch_start, watch_read or store")
return failure("call must be inbox, snapshot, submit, chief_switch, rename_chief, edit_picture, watch_start, watch_read or store")
}
} catch {
return failure(String(describing: error))
Expand All @@ -63,6 +70,23 @@ enum DebugHomeAPI {
"names": chiefNames(id, services: services)])
}

/// `edit_picture`: Move and Scale over the Home view showing `conversation`'s Chief (else the
/// default Chief's); `opened` is whether the editor showed, `saved` whether Save wrote the picture.
@MainActor static func editPicture(_ conversation: String?, path: String, zoom: CGFloat?, save: Bool,
services: AppServices) async -> JSONValue {
let home = services.home
guard let id = conversation ?? home.defaultChiefConversation else { return failure("no Chief conversation") }
#if DEBUG || CMUX_BENCH_HOOKS
guard let view = home.debugHostViews.allObjects.first(where: { $0.debugConversation == id && $0.window != nil }) else {
return failure("no Home view shows \(id)")
}
let result = await view.debugEditPicture(URL(fileURLWithPath: path), zoom: zoom, save: save)
return .object(["ok": .bool(result != nil), "opened": .bool(result != nil), "saved": .bool(result == true)])
#else
return failure("edit_picture is DEBUG only")
#endif
}

/// `chief_names`: the Chief's name as its tile, header and tab show it, its record's, whether a
/// rename still shows over the record, the conversation's own title, and `agree` when all
/// shown names are one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@ final class HomeHostView: NSView {
#if DEBUG || CMUX_BENCH_HOOKS
/// `debug.home.drive` engine_set: one pick in this view's Chief settings panel, as its popup does.
func debugPickEngine(_ key: String, _ value: String?) { settingsView.pick(key, value) }
/// `debug.home.api` edit_picture: a picture picked or dropped on this view's Chief card (Move and Scale).
func debugEditPicture(_ source: URL, zoom: CGFloat?, save: Bool) async -> Bool? {
loadAvatar()
return await settingsView.debugEditPicture(source, zoom: zoom, save: save)
}
/// `debug.home.drive` chief_control: the named Chief this view's controls name (nil: the default).
var debugNamedChief: String? { control.named }
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,19 @@ public nonisolated struct HomeChiefAvatars: Sendable {
/// Chief's files.
/// - Throws: when `source` is not a usable picture, or the registry cannot be reached or refuses it.
public func setPicture(from source: URL, chief: String) async throws {
let files = files(chief)
try await setPicture(try await Task.detached { try files.pictureData(from: source) }.value, chief: chief)
}

/// Makes `picture` (a crop the person chose, ``HomeChiefPictureCrop``) Chief `chief`'s, as
/// ``setPicture(from:chief:)`` does with a file's.
/// - Throws: when the registry cannot be reached or refuses it, or the files cannot be written.
public func setPicture(_ picture: ChiefAvatarPicture, chief: String) async throws {
let files = files(chief)
guard let registry else {
try await Task.detached { try files.writePicture(from: source) }.value
try await Task.detached { try files.writePicture(picture) }.value
return
}
let picture = try await Task.detached { try files.pictureData(from: source) }.value
try await registry.setPicture(picture, of: chief)
await Task.detached { files.clearPicture() }.value
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ public nonisolated struct HomeChiefFiles: Sendable {
/// Makes `source` the Chief's picture in its files (a cloud Chief's, which has no registry
/// on this Mac): ``pictureData(from:)`` beside profile.json.
public func writePicture(from source: URL) throws {
let picture = try pictureData(from: source)
try writePicture(try pictureData(from: source))
}

/// Makes `picture` the Chief's picture in its files, beside profile.json.
public func writePicture(_ picture: ChiefAvatarPicture) throws {
let folder = profileFile.deletingLastPathComponent()
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let name = picture.mediaType == "image/gif" ? "avatar.gif" : "avatar.png"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
public import CoreGraphics
public import Foundation
import ImageIO
import UniformTypeIdentifiers

/// The part of a picture a Chief wears, as Messages' contact photo editor picks it: a square
/// over the picture (the circle the editor shows sits in it), never past its edges, moved by
/// dragging and made smaller by zooming in.
///
/// Coordinates are the upright picture's pixels with the origin top left (ImageIO's, after its
/// orientation is applied). ``rect`` is the square; ``render(_:)`` makes the saved picture.
///
/// ```swift
/// var crop = HomeChiefPictureCrop(imageSize: CGSize(width: 4000, height: 3000))
/// crop.zoom = 2 // a 1500-pixel square, centered
/// crop.pan(by: CGSize(width: 40, height: 0), diameter: 240) // the picture follows the drag
/// let picture = try crop.render(image) // 512 x 512 PNG
/// ```
public nonisolated struct HomeChiefPictureCrop: Hashable, Sendable {
/// The most the editor zooms in: the square is a fifth of the picture's shorter side.
public static let maximumZoom: CGFloat = 5
/// The saved picture's side in pixels (what `pictureData(from:)` keeps for a file).
public static let outputPixels = 512
/// The longest side a picture is read at for the editor: more than any crop saves.
public static let editorPixels = 2048

/// The upright picture's size in pixels.
public let imageSize: CGSize
/// 1 shows the largest square (the picture's shorter side); ``maximumZoom`` the smallest.
public var zoom: CGFloat { didSet { zoom = min(max(zoom, 1), Self.maximumZoom); clamp() } }
/// The square's center in picture pixels; kept so the square stays on the picture.
public private(set) var center: CGPoint

public init(imageSize: CGSize) {
self.imageSize = imageSize
zoom = 1
center = CGPoint(x: imageSize.width / 2, y: imageSize.height / 2)
}

/// The square's side in picture pixels.
public var side: CGFloat { min(imageSize.width, imageSize.height) / zoom }

/// The square, in picture pixels (origin top left).
public var rect: CGRect {
CGRect(x: center.x - side / 2, y: center.y - side / 2, width: side, height: side)
}

/// Moves the picture by a drag of `delta` points (AppKit's: y up) in an editor whose circle
/// is `diameter` points across: the picture follows the pointer, so the square goes the other way.
public mutating func pan(by delta: CGSize, diameter: CGFloat) {
guard diameter > 0 else { return }
let pixelsPerPoint = side / diameter
center.x -= delta.width * pixelsPerPoint
center.y += delta.height * pixelsPerPoint
clamp()
}

private mutating func clamp() {
let half = side / 2
center.x = min(max(center.x, half), max(imageSize.width - half, half))
center.y = min(max(center.y, half), max(imageSize.height - half, half))
}

/// The square of `image` (the upright picture this crop was made for) as a PNG of
/// ``outputPixels`` a side, or smaller when the square is smaller.
/// - Throws: when the picture cannot be drawn or encoded.
public func render(_ image: CGImage) throws -> ChiefAvatarPicture {
let scaleX = CGFloat(image.width) / max(imageSize.width, 1)
let scaleY = CGFloat(image.height) / max(imageSize.height, 1)
let source = CGRect(x: rect.minX * scaleX, y: rect.minY * scaleY, width: rect.width * scaleX, height: rect.height * scaleY).integral
guard let square = image.cropping(to: source) else { throw CocoaError(.fileReadCorruptFile) }
let pixels = min(Self.outputPixels, max(square.width, 1))
guard let context = CGContext(data: nil, width: pixels, height: pixels, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else { throw CocoaError(.fileWriteUnknown) }
context.interpolationQuality = .high
context.draw(square, in: CGRect(x: 0, y: 0, width: pixels, height: pixels))
let data = NSMutableData()
guard let output = context.makeImage(),
let destination = CGImageDestinationCreateWithData(data as CFMutableData, UTType.png.identifier as CFString, 1, nil)
else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(destination, output, nil)
guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) }
return ChiefAvatarPicture(mediaType: "image/png", data: data as Data)
}

/// The upright picture at `url`, at most ``editorPixels`` on its longer side, for the editor;
/// nil when ImageIO cannot read it. Reads the file: call it off the main actor.
public static func editorImage(at url: URL) -> CGImage? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), CGImageSourceGetCount(source) > 0 else { return nil }
let options: [CFString: Any] = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: editorPixels]
return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}

/// Whether the picture at `url` is a GIF, which is kept whole (its frames move), not cropped.
public static func isAnimated(_ url: URL) -> Bool {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let type = (CGImageSourceGetType(source) as String?).flatMap(UTType.init) else { return false }
return type.conforms(to: .gif)
}
}
Loading
Loading