nvrsion: Add: Fix tab bar animation delay when exiting session chat to ensure immediate slide-up (Models, AdaptiveRootView, SessionsView), update iOS settings to replace “Join a Mac’s group” with “Join an Orbital mesh” and add QR code button for device grouping.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -92,6 +92,9 @@ final class HostConnection {
|
|||||||
var meshRosterChanged: () -> Void = {}
|
var meshRosterChanged: () -> Void = {}
|
||||||
/// The embedded Tailscale node's status changed (for Settings).
|
/// The embedded Tailscale node's status changed (for Settings).
|
||||||
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
|
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
|
||||||
|
/// A join code this host minted at our request ("add a device to this mesh") — the
|
||||||
|
/// `nucleic://pair?d=…` string to show as a QR / copyable code, or nil if it couldn't.
|
||||||
|
var pairingCodeReceived: (String?) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
private let callbacks: Callbacks
|
private let callbacks: Callbacks
|
||||||
|
|
||||||
@@ -593,6 +596,10 @@ final class HostConnection {
|
|||||||
pinnedHost?.relayMembershipToken = membership.token
|
pinnedHost?.relayMembershipToken = membership.token
|
||||||
pinnedHost?.relayURL = membership.url
|
pinnedHost?.relayURL = membership.url
|
||||||
}
|
}
|
||||||
|
case .pairingCode(let qr):
|
||||||
|
// This Mac minted a join code we asked for ("add a device to this mesh") — hand it up
|
||||||
|
// to RemoteStore for the QR/copy sheet.
|
||||||
|
callbacks.pairingCodeReceived(qr)
|
||||||
case .wireError(let error):
|
case .wireError(let error):
|
||||||
if error.code == .channelMismatch {
|
if error.code == .channelMismatch {
|
||||||
connectivity = .failed(error.message)
|
connectivity = .failed(error.message)
|
||||||
|
|||||||
@@ -66,11 +66,32 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var openSessionHostID: String?
|
private var openSessionHostID: String?
|
||||||
@Published private(set) var openEvents: [AgentEvent] = []
|
@Published private(set) var openEvents: [AgentEvent] = []
|
||||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||||
|
/// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
|
||||||
|
/// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
|
||||||
|
/// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
|
||||||
|
/// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe.
|
||||||
|
/// `SessionsView` sets this straight from its navigation path, which flips the instant the pop
|
||||||
|
/// begins, so the bar reflows immediately while the transcript state survives the animation.
|
||||||
|
@Published var compactDetailPresented = false
|
||||||
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
||||||
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
|
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
|
||||||
@Published private(set) var openDiff: WireSessionDiff?
|
@Published private(set) var openDiff: WireSessionDiff?
|
||||||
@Published private(set) var diffLoading = false
|
@Published private(set) var diffLoading = false
|
||||||
|
|
||||||
|
/// "Add a device to this mesh" (Settings): the phone asks a connected Mac to mint a join code
|
||||||
|
/// and shows it as a QR / copyable link. The phone can't mint one itself (it runs no listener),
|
||||||
|
/// so this is always relayed through a live, capable host.
|
||||||
|
enum AddDeviceState: Equatable {
|
||||||
|
case idle
|
||||||
|
case requesting // waiting on the host's `pairingCode` reply
|
||||||
|
case ready(String) // the `nucleic://pair?d=…` join code to display
|
||||||
|
case unavailable // no live host could mint one right now
|
||||||
|
}
|
||||||
|
@Published private(set) var addDevice: AddDeviceState = .idle
|
||||||
|
/// The host we asked to mint the current code, so a dismiss can tell the *same* Mac to close
|
||||||
|
/// its pairing window.
|
||||||
|
private var pairingMintHostID: String?
|
||||||
|
|
||||||
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
|
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
|
||||||
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
|
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
|
||||||
struct LastError: Equatable, Identifiable {
|
struct LastError: Equatable, Identifiable {
|
||||||
@@ -533,6 +554,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
self?.tailnetStatus = status
|
self?.tailnetStatus = status
|
||||||
self?.tailnetLoginURL = loginURL
|
self?.tailnetLoginURL = loginURL
|
||||||
}
|
}
|
||||||
|
cb.pairingCodeReceived = { [weak self] qr in
|
||||||
|
guard let self else { return }
|
||||||
|
// Only apply while a request is outstanding — ignore a late reply after the user
|
||||||
|
// dismissed the sheet (we already told the host to cancel).
|
||||||
|
guard case .requesting = self.addDevice else { return }
|
||||||
|
self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable
|
||||||
|
}
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,6 +692,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// aggregate and spin the embedded Tailscale node down.
|
/// aggregate and spin the embedded Tailscale node down.
|
||||||
private func finishUnpairIfEmpty() {
|
private func finishUnpairIfEmpty() {
|
||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
|
compactDetailPresented = false
|
||||||
openSessionHostID = nil
|
openSessionHostID = nil
|
||||||
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||||
connectivity = .unpaired
|
connectivity = .unpaired
|
||||||
@@ -694,6 +723,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
demoTasks.forEach { $0.cancel() }
|
demoTasks.forEach { $0.cancel() }
|
||||||
demoTasks.removeAll()
|
demoTasks.removeAll()
|
||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
|
compactDetailPresented = false
|
||||||
openSessionHostID = nil
|
openSessionHostID = nil
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
@@ -742,6 +772,44 @@ final class RemoteStore: ObservableObject {
|
|||||||
send(.fetchDiff(sessionID))
|
send(.fetchDiff(sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Add a device to this mesh (relayed pairing-code mint)
|
||||||
|
|
||||||
|
/// Whether the "add a device" button should appear: a live Mac that advertises
|
||||||
|
/// `canMintPairingCode` is reachable to mint a join code (or we're in demo).
|
||||||
|
var canAddDeviceToMesh: Bool {
|
||||||
|
demoMode || pairingMintConnection != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection we route a mint request to: prefer the context host if it can mint, else any
|
||||||
|
/// live host that can. A code minted by *any* mesh member joins the whole group (mesh "join").
|
||||||
|
private var pairingMintConnection: HostConnection? {
|
||||||
|
if let ctx = contextConnection, ctx.connectivity.isLive, ctx.capabilities.canMintPairingCode {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return connections.values.first { $0.connectivity.isLive && $0.capabilities.canMintPairingCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a connected Mac to open a pairing window and hand back its join code. The reply arrives
|
||||||
|
/// asynchronously as `AddDeviceState.ready` (or `.unavailable`) via the host connection.
|
||||||
|
func requestPairingCode() {
|
||||||
|
if demoMode { addDevice = .ready(Self.demoPairingCode); return }
|
||||||
|
guard let conn = pairingMintConnection else { addDevice = .unavailable; return }
|
||||||
|
pairingMintHostID = conn.hostID
|
||||||
|
addDevice = .requesting
|
||||||
|
conn.send(.requestPairingCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing
|
||||||
|
/// window (retire the one-time secret) and reset to idle.
|
||||||
|
func cancelPairingCode() {
|
||||||
|
if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) }
|
||||||
|
pairingMintHostID = nil
|
||||||
|
addDevice = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
||||||
|
private static let demoPairingCode = "nucleic://pair?d=demo"
|
||||||
|
|
||||||
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||||
func markOpened(_ sessionID: SessionID) {
|
func markOpened(_ sessionID: SessionID) {
|
||||||
lastOpenedAt[sessionID] = Date()
|
lastOpenedAt[sessionID] = Date()
|
||||||
@@ -936,9 +1004,12 @@ final class RemoteStore: ObservableObject {
|
|||||||
broadcastLive(msg)
|
broadcastLive(msg)
|
||||||
|
|
||||||
// Never originated from here (connection-internal, or handled by dedicated loops).
|
// Never originated from here (connection-internal, or handled by dedicated loops).
|
||||||
|
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
||||||
|
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||||
.registerLiveActivity, .endLiveActivity,
|
.registerLiveActivity, .endLiveActivity,
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript:
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||||
|
.requestPairingCode, .cancelPairingCode:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1117,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
.registerLiveActivity, .endLiveActivity,
|
.registerLiveActivity, .endLiveActivity,
|
||||||
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
||||||
// and demo has no peer Macs, so they're inert here.
|
// and demo has no peer Macs, so they're inert here.
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
|
||||||
|
// "Add a device" mint is handled directly against a live host, not via demoHandle;
|
||||||
|
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
||||||
|
.requestPairingCode, .cancelPairingCode:
|
||||||
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ struct CompactRootView: View {
|
|||||||
@State private var barHeight: CGFloat = 72
|
@State private var barHeight: CGFloat = 72
|
||||||
|
|
||||||
/// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's
|
/// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's
|
||||||
/// floating glass chat bar (`openSessionID`, set by `SessionDetailView`'s appear/disappear) and
|
/// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation
|
||||||
/// to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the composer
|
/// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the
|
||||||
/// doesn't just stack on top of a lingering tab bar.
|
/// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than
|
||||||
private var showsTabBar: Bool { store.openSessionID == nil && !showNewChat }
|
/// `openSessionID` because the path flips the instant a back-swipe begins, whereas
|
||||||
|
/// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop
|
||||||
|
/// transition — which left the bar sliding back up 1–2s late.
|
||||||
|
private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat }
|
||||||
|
|
||||||
/// The "+" is inert without control scope or a project to start in, so it's shown only when a
|
/// The "+" is inert without control scope or a project to start in, so it's shown only when a
|
||||||
/// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`).
|
/// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`).
|
||||||
|
|||||||
@@ -73,9 +73,18 @@ struct SessionsView: View {
|
|||||||
path.append(SessionID(rawValue: raw))
|
path.append(SessionID(rawValue: raw))
|
||||||
}
|
}
|
||||||
consumeRoute()
|
consumeRoute()
|
||||||
|
// Sync the shell's tab-bar visibility to the current push depth on (re)appear —
|
||||||
|
// `onChange` alone won't fire for a path restored across a remount.
|
||||||
|
store.compactDetailPresented = !path.isEmpty
|
||||||
}
|
}
|
||||||
// A notification tap while this tab is already up.
|
// A notification tap while this tab is already up.
|
||||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||||
|
// Drive the floating tab bar straight off the navigation path: it empties the instant a
|
||||||
|
// back-swipe/tap begins, so the bar slides back in immediately instead of waiting for
|
||||||
|
// `SessionDetailView.onDisappear` (which fires only at the end of the pop transition).
|
||||||
|
.onChange(of: path) { _, newPath in
|
||||||
|
store.compactDetailPresented = !newPath.isEmpty
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
import NucleicTailnet
|
import NucleicTailnet
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -7,6 +8,7 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
@State private var showManualPair = false
|
@State private var showManualPair = false
|
||||||
|
@State private var showAddDevice = false
|
||||||
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
|
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
|
||||||
@State private var pairedHostsToken = UUID()
|
@State private var pairedHostsToken = UUID()
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@@ -138,10 +140,20 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
// Grow the mesh: ask a connected Mac to mint a join code this phone shows —
|
||||||
|
// scan it from a new iPhone/iPad, or copy the link to a new Mac. Only when a
|
||||||
|
// live Mac can mint one (the phone can't originate a join code on its own).
|
||||||
|
if store.canAddDeviceToMesh {
|
||||||
|
Button {
|
||||||
|
showAddDevice = true
|
||||||
|
} label: {
|
||||||
|
Label("Add a device to this mesh", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
showScanner = true
|
showScanner = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Join a Mac's group", systemImage: "qrcode.viewfinder")
|
Label("Join an Orbital mesh", systemImage: "qrcode.viewfinder")
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
showManualPair = true
|
showManualPair = true
|
||||||
@@ -171,6 +183,9 @@ struct SettingsView: View {
|
|||||||
store.pair(with: payload)
|
store.pair(with: payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) {
|
||||||
|
AddDeviceView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,3 +370,118 @@ struct ManualPairingView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Add a device to this mesh": the phone can't mint a join code itself (it runs no listener), so
|
||||||
|
/// it asks a connected Mac to open a pairing window and relays back that Mac's code — shown here as
|
||||||
|
/// a QR to scan from a new iPhone/iPad, or a copyable link to send to a new Mac. Scanning/pasting it
|
||||||
|
/// joins the new device to the whole group. Dismissing the sheet closes the Mac's pairing window
|
||||||
|
/// (`RemoteStore.cancelPairingCode`, wired to the sheet's `onDismiss`).
|
||||||
|
struct AddDeviceView: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var copied = false
|
||||||
|
@State private var copyResetTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
switch store.addDevice {
|
||||||
|
case .idle, .requesting:
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Asking a Mac in your mesh for a join code…")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .unavailable:
|
||||||
|
Section {
|
||||||
|
Label("Couldn't get a join code", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("This needs a Mac in your mesh that's connected right now. Make sure "
|
||||||
|
+ "one is reachable with its meshing service on, then try again.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
Button("Try again") { store.requestPairingCode() }
|
||||||
|
}
|
||||||
|
case .ready(let code):
|
||||||
|
codeSection(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add a device")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to
|
||||||
|
// close its pairing window.
|
||||||
|
.onAppear { if case .ready = store.addDevice {} else { store.requestPairingCode() } }
|
||||||
|
.onDisappear { copyResetTask?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func codeSection(_ code: String) -> some View {
|
||||||
|
Section {
|
||||||
|
if let image = Self.qrImage(code) {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Image(uiImage: image)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 220, height: 220)
|
||||||
|
.padding(10)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Scan to join")
|
||||||
|
} footer: {
|
||||||
|
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. "
|
||||||
|
+ "It joins the whole group — every Mac and device here.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text(code)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Button {
|
||||||
|
copyCode(code)
|
||||||
|
} label: {
|
||||||
|
Label(copied ? "Copied" : "Copy join code",
|
||||||
|
systemImage: copied ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Or send a code")
|
||||||
|
} footer: {
|
||||||
|
Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or "
|
||||||
|
+ "iPad. A Mac joining is confirmed on the Mac that shared the code.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyCode(_ code: String) {
|
||||||
|
UIPasteboard.general.string = code
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) { copied = true }
|
||||||
|
copyResetTask?.cancel()
|
||||||
|
copyResetTask = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.4))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
withAnimation(.easeIn(duration: 0.2)) { copied = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the join string as a QR code (CoreImage), mirroring the Mac's pairing sheet.
|
||||||
|
private static func qrImage(_ string: String) -> UIImage? {
|
||||||
|
let filter = CIFilter.qrCodeGenerator()
|
||||||
|
filter.message = Data(string.utf8)
|
||||||
|
filter.correctionLevel = "M"
|
||||||
|
guard let output = filter.outputImage else { return nil }
|
||||||
|
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||||
|
let context = CIContext()
|
||||||
|
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||||
|
return UIImage(cgImage: cg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user