mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix: package bundled media engines
This commit is contained in:
@@ -63,16 +63,18 @@ jobs:
|
||||
- name: Fetch media engines
|
||||
run: |
|
||||
mkdir -p Sources/Firelink
|
||||
|
||||
|
||||
# Download latest yt-dlp for macOS
|
||||
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
|
||||
chmod +x Sources/Firelink/yt-dlp
|
||||
|
||||
# Download ffmpeg (Martin Riedl's static macOS ARM64 build)
|
||||
curl -sL "https://ffmpeg.martin-riedl.de/download/macos/arm64/1779741465_9_1_1/ffmpeg.zip" -o ffmpeg.zip
|
||||
|
||||
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
|
||||
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
|
||||
unzip -q -o ffmpeg.zip -d Sources/Firelink
|
||||
rm ffmpeg.zip
|
||||
chmod +x Sources/Firelink/ffmpeg
|
||||
test -x Sources/Firelink/yt-dlp
|
||||
test -x Sources/Firelink/ffmpeg
|
||||
|
||||
- name: Build app bundle
|
||||
env:
|
||||
@@ -106,7 +108,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
|
||||
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
else
|
||||
|
||||
@@ -23,3 +23,4 @@ SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
ffmpeg
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: c45809038e...2b24d69977
@@ -44,7 +44,7 @@
|
||||
## ✨ Features
|
||||
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter—backed securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
|
||||
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
||||
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
||||
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
||||
|
||||
@@ -24,6 +24,16 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
||||
|
||||
for media_engine in yt-dlp ffmpeg; do
|
||||
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
|
||||
if [[ -x "$media_engine_path" ]]; then
|
||||
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
||||
chmod +x "$RESOURCES_DIR/$media_engine"
|
||||
else
|
||||
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Packaging Firefox extension..."
|
||||
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
|
||||
|
||||
@@ -208,26 +208,6 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
|
||||
var item = item
|
||||
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
|
||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
||||
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
||||
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
|
||||
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
|
||||
downloads.append(item)
|
||||
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
|
||||
saveDownloads()
|
||||
|
||||
if startImmediately {
|
||||
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
|
||||
}
|
||||
}
|
||||
|
||||
func startQueue(queueID: UUID? = nil) {
|
||||
engineMessage = ""
|
||||
restrictQueueToAutoResume = false
|
||||
@@ -534,7 +514,7 @@ final class DownloadController: ObservableObject {
|
||||
do {
|
||||
update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.message = "Checking media add-ons..."
|
||||
$0.message = "Checking bundled media engines..."
|
||||
}
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
|
||||
guard let liveItem = activeDownloadItem(id: item.id) else { return }
|
||||
|
||||
@@ -8,15 +8,11 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
|
||||
// IMPORTANT(Backward Compatibility):
|
||||
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
|
||||
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
|
||||
// without maintaining backward compatibility for older extensions.
|
||||
// If you need to update the API (e.g., changing the payload structure), add the new
|
||||
// token here and handle both versions in `processRequest`.
|
||||
|
||||
// Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here
|
||||
// when future store releases need a non-breaking local API transition.
|
||||
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
|
||||
|
||||
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||
|
||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
|
||||
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
|
||||
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import Combine
|
||||
|
||||
enum AddonState: Equatable, Sendable {
|
||||
case notInstalled
|
||||
case downloading(progress: Double)
|
||||
case installed(version: String)
|
||||
case failed(error: String)
|
||||
}
|
||||
@@ -32,9 +31,11 @@ final class MediaEngineManager: ObservableObject {
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
for bundle in [Bundle.main, Bundle.module] {
|
||||
if let bundled = bundle.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -55,18 +56,18 @@ final class MediaEngineManager: ObservableObject {
|
||||
switch state(for: addon) {
|
||||
case .installed:
|
||||
return false
|
||||
case .downloading, .notInstalled, .failed:
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !missingAddons.isEmpty else { return }
|
||||
|
||||
|
||||
for missing in missingAddons {
|
||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||
}
|
||||
|
||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle."])
|
||||
|
||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
||||
}
|
||||
|
||||
private func state(for addon: AddonType) -> AddonState {
|
||||
|
||||
@@ -51,7 +51,7 @@ enum MediaExtractionEngine {
|
||||
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
||||
case .invalidOutput: return "Invalid output from media engine."
|
||||
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
||||
case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source."
|
||||
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MediaInspectorInlineView: View {
|
||||
let url: URL
|
||||
let cookieSource: BrowserCookieSource
|
||||
let credentials: DownloadCredentials?
|
||||
let transferOptions: DownloadTransferOptions
|
||||
let onCancel: () -> Void
|
||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
||||
|
||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var statusText = "Checking Media Engine..."
|
||||
@State private var metadata: MediaMetadata?
|
||||
@State private var options: [CleanFormatOption] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
enum MediaType: String, CaseIterable, Identifiable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var selectedType: MediaType = .video
|
||||
@State private var selectedVideoQuality: String = "Best"
|
||||
@State private var selectedVideoFormat: String = "MP4"
|
||||
@State private var selectedAudioFormat: String = "MP3"
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
|
||||
let ytState = engineManager.ytDlpState
|
||||
let ffState = engineManager.ffmpegState
|
||||
|
||||
if case let .downloading(p) = ytState, p > 0 {
|
||||
Text("Downloading yt-dlp: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case let .downloading(p) = ffState, p > 0 {
|
||||
Text("Downloading ffmpeg: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(statusText)
|
||||
.foregroundStyle(.secondary)
|
||||
cookieStatusLabel
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
} else if let errorMessage {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("Retry") {
|
||||
loadMetadata()
|
||||
}
|
||||
} else if let metadata {
|
||||
if let thumbnail = metadata.thumbnail {
|
||||
AsyncImage(url: thumbnail) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: 80, height: 50)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(metadata.title ?? "Unknown Title")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Picker("Type", selection: $selectedType) {
|
||||
ForEach(availableTypes) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
|
||||
if selectedType == .video {
|
||||
Picker("Quality", selection: $selectedVideoQuality) {
|
||||
ForEach(availableVideoQualities, id: \.self) { q in
|
||||
Text(q).tag(q)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
|
||||
Picker("Format", selection: $selectedVideoFormat) {
|
||||
ForEach(availableVideoFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
} else {
|
||||
Picker("Format", selection: $selectedAudioFormat) {
|
||||
ForEach(availableAudioFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
}
|
||||
}
|
||||
|
||||
if let selected = resolveSelectedOption() {
|
||||
Text(selected.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button("Extract") {
|
||||
if let selected = resolveSelectedOption() {
|
||||
onDownload(selected, metadata)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(resolveSelectedOption() == nil)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
.onDisappear {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
}
|
||||
.onChange(of: url) { _, _ in loadMetadata() }
|
||||
.onChange(of: cookieSource) { _, _ in loadMetadata() }
|
||||
.onChange(of: credentials) { _, _ in loadMetadata() }
|
||||
.onChange(of: transferOptions) { _, _ in loadMetadata() }
|
||||
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
|
||||
.onChange(of: options) { _, _ in ensureValidSelection() }
|
||||
}
|
||||
|
||||
private var availableTypes: [MediaType] {
|
||||
var types: [MediaType] = []
|
||||
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
|
||||
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
|
||||
return types
|
||||
}
|
||||
|
||||
private var availableVideoQualities: [String] {
|
||||
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
|
||||
return NSOrderedSet(array: qualities).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableVideoFormats: [String] {
|
||||
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableAudioFormats: [String] {
|
||||
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private func ensureValidSelection() {
|
||||
if !availableTypes.contains(selectedType), let first = availableTypes.first {
|
||||
selectedType = first
|
||||
}
|
||||
if selectedType == .video {
|
||||
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
|
||||
selectedVideoQuality = first
|
||||
}
|
||||
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
|
||||
selectedVideoFormat = first
|
||||
}
|
||||
} else {
|
||||
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
|
||||
selectedAudioFormat = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSelectedOption() -> CleanFormatOption? {
|
||||
if selectedType == .video {
|
||||
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
|
||||
} else {
|
||||
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cookieStatusLabel: some View {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Browser cookies off", systemImage: "circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
loadTask?.cancel()
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
metadata = nil
|
||||
options = []
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
await MainActor.run { statusText = "Checking yt-dlp..." }
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run { statusText = "Fetching Metadata..." }
|
||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: url,
|
||||
cookieSource: cookieSource,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
if fetchedOptions.isEmpty {
|
||||
self.errorMessage = "No downloadable media formats were found."
|
||||
} else {
|
||||
self.metadata = fetchedMetadata
|
||||
self.options = fetchedOptions
|
||||
self.ensureValidSelection()
|
||||
}
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ struct EngineSettingsPane: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
|
||||
LabeledContent("Binary Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
@@ -42,9 +42,9 @@ struct EngineSettingsPane: View {
|
||||
|
||||
Section {
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||
|
||||
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
@@ -59,7 +59,7 @@ struct EngineSettingsPane: View {
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Powers video and audio extraction from supported sites.")
|
||||
|
||||
|
||||
if settings.mediaCookieSource != .none {
|
||||
Text(settings.mediaCookieSource.statusDetail)
|
||||
}
|
||||
@@ -80,8 +80,6 @@ struct EngineSettingsPane: View {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .downloading:
|
||||
Text("Unavailable")
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -91,7 +89,7 @@ struct EngineSettingsPane: View {
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Reference in New Issue
Block a user