diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift index ea1d22e31a8f..034017a43fc3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift @@ -169,7 +169,13 @@ struct PageFactory { set: { [weak services] value, id in services?.themes.setWorkspaceOpacity(value, for: id) }, preview: { [weak services] value, id in services?.themes.previewWorkspaceOpacity(value, for: id) })) let native = AppPageNativeProvider(services: services, page: .settings) + // Your own backgrounds from a link, removed or revealed (BackdropImagesPageProvider). + let images = provider.backdropImages.map { images in + BackdropImagesPageProvider(images: images.images, use: { [weak provider] in try await provider?.useBackdropImage($0) ?? "" }, + inUse: { [weak services] in services?.settings?.snapshot.backdropSelection }) + } let routes = [PageRoute(prefix: "cmux.settings.", provider: provider), PageRoute(prefix: "cmux.app.", provider: native)] + + (images.map { [PageRoute(prefix: BackdropImagesPageProvider.prefix, provider: $0)] } ?? []) // `appearance.surfaces.settings` colors the page, as it colored the Swift Settings view. let dynamicResources = SettingsBackdropThumbnails(choices: SettingsWindowService.backdrops.choices, currentChoices: { SettingsWindowService.backdrops.choices }, @@ -183,10 +189,8 @@ struct PageFactory { ?? PageWebView(descriptor: .settings, routes: routes, route: route, surface: .settings, dynamicResources: dynamicResources) native.anchor = { [weak page] in page } - // An image dropped on Settings becomes the background (cx-t2x.8), while the picker shows. - page?.fileDrop = PageFileDrop(accepts: { [weak services] url in - services?.settings?.snapshot.experimentalAppearance == true && BackdropUserImages.isImage(url) - }) { [weak provider] url in + // An image or movie dropped on Settings becomes the background (cx-t2x.8). + page?.fileDrop = PageFileDrop(accepts: { url in BackdropUserImages.isImage(url) }) { [weak provider] url in // task-owner: one short copy and settings write per drop; a failed one changes nothing Task { try? await provider?.useBackdropImage(url) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/SettingsPageProvider+Backdrop.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/SettingsPageProvider+Backdrop.swift index 6cdff952693a..9e5ed327272f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/SettingsPageProvider+Backdrop.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/SettingsPageProvider+Backdrop.swift @@ -36,11 +36,10 @@ extension SettingsPageProvider { guard let descriptor = SettingsSchema.descriptor(for: BackdropSelectionSetting().configPath) else { throw PageError(code: "cmux.page.unavailable", message: "no background setting") } - let images = backdropImages.images let selection: BackdropSelection do { - // The copy runs off the main actor: a photo can be tens of megabytes. - selection = try await Task.detached { try images.adopt(url) }.value + // Checked to decode or play, then copied off the main actor. + selection = try await backdropImages.images.adoptChecked(url) } catch { throw PageError(code: "cmux.settings.invalid", message: "\(url.lastPathComponent) cannot be used as a background") } @@ -49,10 +48,3 @@ extension SettingsPageProvider { return selection.id } } - -/// Where the Settings page keeps the person's own background images, and how it asks for one. -struct SettingsBackdropImages { - let images: BackdropUserImages - /// The cmux file picker; nil when the person left it. - let pick: @MainActor () async -> URL? -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/BackdropUserImages+Import.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/BackdropUserImages+Import.swift new file mode 100644 index 000000000000..85626c742a19 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/BackdropUserImages+Import.swift @@ -0,0 +1,266 @@ +public import Foundation +import AVFoundation +import ImageIO +import CmuxNextCompat +import UniformTypeIdentifiers + +/// Bringing a background in (cx-t2x.8, backgrounds plan phase 3): a file is checked before it is +/// copied, so the folder never lists a picture that will not decode or a movie macOS cannot play, +/// and a link downloads with a size cap and is judged by its bytes, not its name. +nonisolated extension BackdropUserImages { + /// Why a file or link cannot be a background; `code` is what the Settings page shows. + public nonisolated struct ImportError: Error, Equatable, Sendable { + public let code: String + + /// Not an https link. + public static let notHTTPS = Self(code: "cmux.settings.backdrop.not_https") + /// Bigger than ``maximumImageBytes`` or ``maximumVideoBytes``. + public static let tooLarge = Self(code: "cmux.settings.backdrop.too_large") + /// Neither a picture that decodes nor a movie macOS can play. + public static let notMedia = Self(code: "cmux.settings.backdrop.not_media") + /// The download failed. + public static let downloadFailed = Self(code: "cmux.settings.backdrop.download_failed") + } + + /// The largest picture a link may bring. + public nonisolated static let maximumImageBytes: Int64 = 50 * 1024 * 1024 + /// The largest movie a link may bring. + public nonisolated static let maximumVideoBytes: Int64 = 500 * 1024 * 1024 + + /// Copies `source` into the folder after checking it decodes (a picture) or plays (a movie). + public func adoptChecked(_ source: URL) async throws -> BackdropSelection { + guard await Self.isUsable(source) else { throw ImportError.notMedia } + let images = self + // The copy runs off the main actor: a movie can be hundreds of megabytes. + return try await Task.detached { try images.adopt(source) }.value + } + + /// Whether `url` holds a picture that decodes or a movie with a picture macOS can play. + public nonisolated static func isUsable(_ url: URL) async -> Bool { + if AppleWallpapers.isVideo(url.path) { + let asset = AVURLAsset(url: url) + guard (try? await asset.load(.isPlayable)) == true else { return false } + return !((try? await asset.loadTracks(withMediaType: .video)) ?? []).isEmpty + } + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), CGImageSourceGetCount(source) > 0 else { return false } + return CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceThumbnailMaxPixelSize: 64, + kCGImageSourceCreateThumbnailFromImageAlways: true] as CFDictionary) != nil + } + + /// Downloads the picture or movie at `link` (https only, redirects included) into a fresh + /// temporary folder and returns the file, named for what its bytes are. The caller adopts it + /// (``adoptChecked(_:)``) and removes its folder. The download stops as soon as it passes the + /// cap for what the server says it is (``maximumImageBytes`` for a picture, else + /// ``maximumVideoBytes``), so a huge file never lands on disk whole. + public nonisolated static func download(_ link: URL, configuration: URLSessionConfiguration = .ephemeral) async throws -> URL { + guard isHTTPS(link) else { throw ImportError.notHTTPS } + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-background-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let file = folder.appendingPathComponent("download") + let mimeType = try await LinkDownload(file: file).run(link, configuration: configuration) + let named = try named(file, link: link, mimeType: mimeType) + let size = (try? named.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0 + guard size <= (AppleWallpapers.isVideo(named.path) ? maximumVideoBytes : maximumImageBytes) else { throw ImportError.tooLarge } + return named + } catch { + try? FileManager.default.removeItem(at: folder) + throw error + } + } + + nonisolated static func isHTTPS(_ url: URL?) -> Bool { + url?.scheme?.lowercased() == "https" && url?.host?.isEmpty == false + } + + /// The cap for a body the server calls `mimeType`. + nonisolated static func cap(for mimeType: String?) -> Int64 { + mimeType?.lowercased().hasPrefix("image/") == true ? maximumImageBytes : maximumVideoBytes + } + + /// A file name from the link's last path component: its own name only (no folders, no + /// leading dots, nothing the file system reads as a path), at most 80 characters. + nonisolated static func safeStem(_ link: URL) -> String { + // The extension cut at the last dot by hand: Foundation's deletingPathExtension keeps a + // name that is all dots and extension ("...png") whole on some macOS versions. + let name = link.lastPathComponent + let decoded = name.lastIndex(of: ".").map { String(name[..<$0]) } ?? name + let cleaned = String(decoded.map { "/:\\".contains($0) || $0.isNewline ? "-" : $0 }) + let stem = String(cleaned.drop { $0 == "." || $0 == "-" || $0.isWhitespace }.prefix(80)).trimmingCharacters(in: .whitespaces) + return stem.isEmpty ? "Background" : stem + } + + /// `file` renamed for its bytes: a picture's type from ImageIO, else the server's movie type, + /// else the link's own extension when it names a movie. Anything else is not media. + private nonisolated static func named(_ file: URL, link: URL, mimeType: String?) throws -> URL { + var ext: String? + if let source = CGImageSourceCreateWithURL(file as CFURL, nil), let type = CGImageSourceGetType(source) as String? { + ext = UTType(type)?.preferredFilenameExtension + } else if let mime = mimeType, let type = UTType(mimeType: mime), type.conforms(to: .movie) { + ext = type.preferredFilenameExtension + } else if AppleWallpapers.videoExtensions.contains(link.pathExtension.lowercased()) { + ext = link.pathExtension.lowercased() + } + guard let ext, extensions.contains(ext.lowercased()) else { throw ImportError.notMedia } + let named = file.deletingLastPathComponent().appendingPathComponent("\(safeStem(link)).\(ext.lowercased())") + // Never outside the download's own folder, whatever the name. + guard named.deletingLastPathComponent().standardizedFileURL == file.deletingLastPathComponent().standardizedFileURL else { + throw ImportError.notMedia + } + try FileManager.default.moveItem(at: file, to: named) + return named + } + + /// Removes one of the folder's own images (never downloaded art or a file elsewhere). + public func remove(_ selection: BackdropSelection, fileManager: FileManager = .default) throws { + guard contains(selection), case .file(let path) = selection else { throw CocoaError(.fileNoSuchFile) } + try fileManager.removeItem(atPath: path) + } + + /// The bytes the folder's own images take, downloaded art left out. + public func usage(fileManager: FileManager = .default) -> Int64 { + list(fileManager: fileManager).reduce(into: Int64(0)) { total, selection in + guard case .file(let path) = selection else { return } + total += ((try? fileManager.attributesOfItem(atPath: path)[.size]) as? NSNumber)?.int64Value ?? 0 + } + } +} + +/// One link download into `file`: a data task whose delegate checks the declared length and the +/// running count against the cap for what the body is (the declared type, or a picture's own +/// signature in its first bytes), refuses a redirect off https, and writes the body as it arrives. +/// Timeouts are a minute without data and ten minutes in all. +private nonisolated final class LinkDownload: NSObject, URLSessionDataDelegate, Sendable { + private struct State: ~Copyable { + var handle: FileHandle? + var written: Int64 = 0 + var cap = BackdropUserImages.maximumVideoBytes + var sniffed = false + var mimeType: String? + var refusal: BackdropUserImages.ImportError? + var continuation: CheckedContinuation? + var task: URLSessionDataTask? + var cancelled = false + } + + private let file: URL + private let state = Mutex(State()) + + init(file: URL) { + self.file = file + } + + /// The server's media type once the body is on disk. + func run(_ link: URL, configuration: URLSessionConfiguration) async throws -> String? { + try Task.checkCancellation() + let configuration = configuration.copy() as? URLSessionConfiguration ?? .ephemeral + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 600 + configuration.urlCache = nil + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: link) + // A cancel that came before the task existed cancels it now: it then completes + // at once, cancelled, and resumes the continuation. + let cancelled = state.withLock { state in + state.continuation = continuation + state.task = task + return state.cancelled + } + if cancelled { task.cancel() } + task.resume() + } + } onCancel: { + let task = state.withLock { state in + state.cancelled = true + return state.task + } + task?.cancel() + } + } + + private func refuse(_ error: BackdropUserImages.ImportError) { + state.withLock { if $0.refusal == nil { $0.refusal = error } } + } + + /// Whether `data` starts like a picture (PNG, JPEG, GIF, WebP), whatever the server called it. + static func looksLikePicture(_ data: Data) -> Bool { + let bytes = [UInt8](data.prefix(12)) + func starts(_ prefix: [UInt8], at offset: Int = 0) -> Bool { + bytes.count >= offset + prefix.count && Array(bytes[offset.. Void) { + guard BackdropUserImages.isHTTPS(request.url) else { + refuse(.notHTTPS) + task.cancel() + return completionHandler(nil) + } + completionHandler(request) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + let cap = BackdropUserImages.cap(for: response.mimeType) + guard (response as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true else { + refuse(.downloadFailed) + return completionHandler(.cancel) + } + guard response.expectedContentLength <= cap else { + refuse(.tooLarge) + return completionHandler(.cancel) + } + let opened: FileHandle? = FileManager.default.createFile(atPath: file.path, contents: nil) + ? try? FileHandle(forWritingTo: file) : nil + state.withLock { state in + state.cap = cap + state.mimeType = response.mimeType + state.handle = opened + } + if opened == nil { refuse(.downloadFailed) } + completionHandler(opened == nil ? .cancel : .allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + let over = state.withLock { state -> Bool in + // A picture sent as anything else still gets a picture's cap. + if !state.sniffed { + state.sniffed = true + if Self.looksLikePicture(data) { state.cap = min(state.cap, BackdropUserImages.maximumImageBytes) } + } + state.written += Int64(data.count) + guard state.written <= state.cap else { return true } + do { try state.handle?.write(contentsOf: data) } catch { + if state.refusal == nil { state.refusal = .downloadFailed } + return true + } + return false + } + if over { + refuse(.tooLarge) + dataTask.cancel() + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + let (continuation, refusal, mimeType, handle) = state.withLock { state in + let taken = (state.continuation, state.refusal, state.mimeType, state.handle) + state.continuation = nil + state.handle = nil + state.task = nil + return taken + } + try? handle?.close() + if let refusal { continuation?.resume(throwing: refusal) } else if error != nil { + continuation?.resume(throwing: BackdropUserImages.ImportError.downloadFailed) + } else { + continuation?.resume(returning: mimeType) + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/BackdropImagesPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/BackdropImagesPageProvider.swift new file mode 100644 index 000000000000..c84f8e5978c7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/BackdropImagesPageProvider.swift @@ -0,0 +1,107 @@ +public import AppKit +public import CmuxNextDesign +public import CmuxNextSettings +public import CmuxNextCompat +public import Foundation + +/// The person's own backgrounds on the Settings page (backgrounds plan phase 3), beside the +/// picker and drop that `SettingsPageProvider` serves: +/// +/// - `cmux.settings.backdrop.image.link {url}`: downloads an https image or movie and uses it, +/// as a picked file is used; `{id}`. +/// - `cmux.settings.backdrop.image.remove {id}`: deletes one of the folder's own images, never +/// the background in use (`cmux.settings.backdrop.in_use`); `{bytes}`. +/// - `cmux.settings.backdrop.image.reveal {id?}`: shows the image, or the folder, in Finder. +/// - `cmux.settings.backdrop.image.usage`: `{bytes}` the folder's own images take. +/// +/// Errors carry ``BackdropUserImages/ImportError`` codes, which the page words. +@MainActor +public final class BackdropImagesPageProvider: PageProvider { + /// The ops this provider serves (longest prefix wins over `cmux.settings.`). + public static let prefix = "cmux.settings.backdrop.image." + + private let images: BackdropUserImages + private let use: @MainActor (URL) async throws -> String + private let reveal: @MainActor ([URL]) -> Void + private let inUse: @MainActor () -> BackdropSelection? + + /// `use` copies a file into the folder and makes it the background, returning its id (the + /// picker's own path, so a link is checked and written as a picked file is). + /// `inUse` is the background the app shows now, which remove refuses. + public init(images: BackdropUserImages, use: @escaping @MainActor (URL) async throws -> String, + inUse: @escaping @MainActor () -> BackdropSelection? = { nil }, + reveal: @escaping @MainActor ([URL]) -> Void = { NSWorkspace.shared.revealInBackground($0) }) { + self.images = images + self.use = use + self.inUse = inUse + self.reveal = reveal + } + + public func call(_ op: String, params: JSONValue, context: PageCallContext) async throws -> JSONValue { + switch op { + case Self.prefix + "link": + guard let text = params["url"]?.stringValue, + let link = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw Self.error(.notHTTPS) + } + let file: URL + do { + file = try await BackdropUserImages.download(link) + } catch let refused as BackdropUserImages.ImportError { + throw Self.error(refused) + } + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + return ["id": .string(try await use(file))] + case Self.prefix + "remove": + let selection = try own(params) + // Removing the background in use would leave every window blank. + guard selection != inUse() else { + throw PageError(code: "cmux.settings.backdrop.in_use", message: "the background in use cannot be removed") + } + let images = images + try await Task.detached { try images.remove(selection) }.value + return try await usage() + case Self.prefix + "reveal": + if params["id"] != nil, case .file(let path) = try own(params) { + reveal([URL(fileURLWithPath: path)]) + } else { + reveal([images.folder]) + } + return .object([:]) + case Self.prefix + "usage": + return try await usage() + default: + throw PageError.unknownOp(op) + } + } + + /// The folder's own image `params.id` names; anything else is refused. + private func own(_ params: JSONValue) throws -> BackdropSelection { + guard let id = params["id"]?.stringValue, let selection = BackdropSelection(id: id), images.contains(selection) else { + throw PageError.invalidParams("id is not one of your images") + } + return selection + } + + private func usage() async throws -> JSONValue { + let images = images + let bytes = await Task.detached { images.usage() }.value + return ["bytes": .number(Double(bytes))] + } + + private static func error(_ refused: BackdropUserImages.ImportError) -> PageError { + PageError(code: refused.code, message: refused.code) + } +} + +/// Where the Settings page keeps the person's own background images, and how it asks for one. +public struct SettingsBackdropImages { + public let images: BackdropUserImages + /// The cmux file picker; nil when the person left it. + public let pick: @MainActor () async -> URL? + + public init(images: BackdropUserImages, pick: @escaping @MainActor () async -> URL?) { + self.images = images + self.pick = pick + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings index 1be587972fbf..d8e0ccb722c9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings @@ -40279,6 +40279,1326 @@ } } } + }, + "settingsWindow.backdropPicker.fromLink": { + "comment": "Background picker tile that adds an image or video from a web link.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add from a Link" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة من رابط" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodaj s linka" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj fra et link" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Über einen Link hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir desde un enlace" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter depuis un lien" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi da un link" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクから追加" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បន្ថែមពីតំណ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크로 추가" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til fra en lenke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj z linku" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar de um link" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить по ссылке" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่มจากลิงก์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantıdan Ekle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати за посиланням" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thêm từ liên kết" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从链接添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從連結加入" + } + } + } + }, + "settingsWindow.backdropPicker.fromLinkHelp": { + "comment": "Help under the Add from a Link tile.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An image or video on the web." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "صورة أو فيديو على الويب." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Slika ili video s weba." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Et billede eller en video på nettet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Bild oder Video aus dem Web." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Una imagen o un vídeo de la web." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une image ou une vidéo du web." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un’immagine o un video dal web." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web 上の画像または動画。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រូបភាព ឬវីដេអូនៅលើបណ្ដាញ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "웹에 있는 이미지나 동영상." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Et bilde eller en video på nettet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obraz lub wideo z internetu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uma imagem ou vídeo da web." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изображение или видео из интернета." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รูปภาพหรือวิดีโอบนเว็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Web’deki bir görsel veya video." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зображення або відео з інтернету." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hình ảnh hoặc video trên web." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络上的图片或视频。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路上的圖片或影片。" + } + } + } + }, + "settingsWindow.backdropPicker.linkField": { + "comment": "Label and placeholder of the field for a background's web link.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Link to an image or video" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رابط صورة أو فيديو" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Link na sliku ili video" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Link til et billede eller en video" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Link zu einem Bild oder Video" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enlace a una imagen o un vídeo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lien vers une image ou une vidéo" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Link a un’immagine o a un video" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像または動画へのリンク" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "តំណទៅរូបភាព ឬវីដេអូ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 또는 동영상 링크" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lenke til et bilde eller en video" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Link do obrazu lub wideo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Link para uma imagem ou vídeo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ссылка на изображение или видео" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลิงก์ไปยังรูปภาพหรือวิดีโอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Görsel veya video bağlantısı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Посилання на зображення або відео" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Liên kết đến hình ảnh hoặc video" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片或视频链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片或影片連結" + } + } + } + }, + "settingsWindow.backdropPicker.add": { + "comment": "Button that downloads the linked image or video and sets it as the background.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodaj" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បន្ថែម" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thêm" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入" + } + } + } + }, + "settingsWindow.backdropPicker.notHttps": { + "comment": "Shown when a background link is not an https link.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a link that starts with https://." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدم رابطًا يبدأ بـ https://." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koristite link koji počinje s https://." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug et link, der starter med https://." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwende einen Link, der mit https:// beginnt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usa un enlace que empiece por https://." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisez un lien qui commence par https://." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Usa un link che inizi con https://." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https:// で始まるリンクを使ってください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សូមប្រើតំណដែលចាប់ផ្ដើមដោយ https://។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://로 시작하는 링크를 사용하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk en lenke som starter med https://." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użyj linku zaczynającego się od https://." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Use um link que comece com https://." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используйте ссылку, которая начинается с https://." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้ลิงก์ที่ขึ้นต้นด้วย https://" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "https:// ile başlayan bir bağlantı kullanın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використайте посилання, що починається з https://." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hãy dùng liên kết bắt đầu bằng https://." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请使用以 https:// 开头的链接。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請使用以 https:// 開頭的連結。" + } + } + } + }, + "settingsWindow.backdropPicker.notMedia": { + "comment": "Shown when a background link or file is not an image or a video the Mac can play.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That is not an image or a video this Mac can play." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا ليس صورة أو فيديو يمكن لهذا الـ Mac تشغيله." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "To nije slika ni video koji ovaj Mac može reproducirati." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Det er ikke et billede eller en video, som denne Mac kan afspille." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das ist kein Bild und kein Video, das dieser Mac abspielen kann." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No es una imagen ni un vídeo que este Mac pueda reproducir." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce n’est ni une image ni une vidéo que ce Mac peut lire." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è un’immagine né un video che questo Mac può riprodurre." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac で再生できる画像または動画ではありません。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "នេះមិនមែនជារូបភាព ឬវីដេអូដែល Mac នេះអាចចាក់បានទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac에서 재생할 수 있는 이미지나 동영상이 아닙니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette er ikke et bilde eller en video denne Macen kan spille av." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "To nie jest obraz ani wideo, które ten Mac może odtworzyć." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isso não é uma imagem nem um vídeo que este Mac possa reproduzir." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это не изображение и не видео, которое этот Mac может воспроизвести." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "นี่ไม่ใช่รูปภาพหรือวิดีโอที่ Mac เครื่องนี้เล่นได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, bu Mac’in oynatabileceği bir görsel veya video değil." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Це не зображення і не відео, яке цей Mac може відтворити." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đây không phải hình ảnh hay video mà máy Mac này phát được." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这不是此 Mac 能播放的图片或视频。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這不是此 Mac 能播放的圖片或影片。" + } + } + } + }, + "settingsWindow.backdropPicker.tooLarge": { + "comment": "Shown when a linked background file is over the size limit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That file is too big: up to 50 MB for an image, 500 MB for a video." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا الملف كبير جدًا: حتى 50 ميغابايت للصورة و500 ميغابايت للفيديو." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datoteka je prevelika: do 50 MB za sliku, 500 MB za video." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Filen er for stor: op til 50 MB for et billede, 500 MB for en video." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datei ist zu groß: bis zu 50 MB für ein Bild, 500 MB für ein Video." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El archivo es demasiado grande: hasta 50 MB para una imagen y 500 MB para un vídeo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce fichier est trop volumineux : 50 Mo maximum pour une image, 500 Mo pour une vidéo." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il file è troppo grande: fino a 50 MB per un’immagine, 500 MB per un video." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルが大きすぎます。画像は 50 MB、動画は 500 MB までです。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ឯកសារនេះធំពេក៖ រហូតដល់ 50 MB សម្រាប់រូបភាព និង 500 MB សម្រាប់វីដេអូ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일이 너무 큽니다. 이미지는 50MB, 동영상은 500MB까지입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Filen er for stor: opptil 50 MB for et bilde, 500 MB for en video." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Plik jest za duży: do 50 MB dla obrazu, 500 MB dla wideo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O arquivo é grande demais: até 50 MB para imagem e 500 MB para vídeo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файл слишком большой: до 50 МБ для изображения и до 500 МБ для видео." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไฟล์ใหญ่เกินไป: รูปภาพได้ถึง 50 MB และวิดีโอได้ถึง 500 MB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dosya çok büyük: görsel için en fazla 50 MB, video için 500 MB." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Файл завеликий: до 50 МБ для зображення, 500 МБ для відео." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tệp quá lớn: tối đa 50 MB cho hình ảnh, 500 MB cho video." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件太大:图片最多 50 MB,视频最多 500 MB。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案太大:圖片最多 50 MB,影片最多 500 MB。" + } + } + } + }, + "settingsWindow.backdropPicker.downloadFailed": { + "comment": "Shown when a background link could not be downloaded.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That link could not be downloaded." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر تنزيل هذا الرابط." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Link se nije mogao preuzeti." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Linket kunne ikke hentes." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Link konnte nicht geladen werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo descargar el enlace." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de télécharger ce lien." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile scaricare il link." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このリンクをダウンロードできませんでした。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចទាញយកតំណនេះបានទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 링크를 다운로드할 수 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lenken kunne ikke lastes ned." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się pobrać tego linku." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível baixar o link." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось загрузить по этой ссылке." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดาวน์โหลดลิงก์นี้ไม่ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu bağlantı indirilemedi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося завантажити за цим посиланням." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không tải xuống được liên kết này." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法下载此链接。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法下載此連結。" + } + } + } + }, + "settingsWindow.backdropPicker.remove": { + "comment": "Accessibility label of the button that deletes one of your own background images; %@ is its name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を削除" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "លុប %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ öğesini kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вилучити %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Xóa %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除 %@" + } + } + } + }, + "settingsWindow.backdropPicker.usage": { + "comment": "How much disk your own background images take; %@ is a size such as 12 MB.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your images use %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تستخدم صورك %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vaše slike zauzimaju %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dine billeder fylder %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Bilder belegen %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tus imágenes ocupan %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vos images occupent %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le tue immagini occupano %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自分の画像の使用量: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រូបភាពរបស់អ្នកប្រើ %@។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 이미지가 %@ 사용 중입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bildene dine bruker %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Twoje obrazy zajmują %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Suas imagens usam %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваши изображения занимают %@." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รูปภาพของคุณใช้พื้นที่ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Görselleriniz %@ yer kaplıyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ваші зображення займають %@." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hình ảnh của bạn dùng %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的图片占用 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的圖片佔用 %@。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/BackdropImportTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/BackdropImportTests.swift new file mode 100644 index 000000000000..8c5356c87bc8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/BackdropImportTests.swift @@ -0,0 +1,172 @@ +@testable import CmuxNextDesign +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +/// Bringing your own background in (backgrounds plan phase 3): a file is checked to decode or play +/// before it is copied, a link downloads over https only, is named for its bytes and capped in +/// size, and your images can be removed and counted. +@Suite(.serialized) struct BackdropImportTests { + static func folder() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-import-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + /// A 4×4 PNG's bytes. + static func png() throws -> Data { + let data = NSMutableData() + let context = try #require(CGContext(data: nil, width: 4, height: 4, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try #require(context.makeImage()), nil) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } + + @Test func aFileThatDoesNotDecodeIsNeverCopiedIn() async throws { + let source = try Self.folder(), backgrounds = try Self.folder() + let images = BackdropUserImages(folder: backgrounds) + let good = source.appendingPathComponent("Sea.png"), bad = source.appendingPathComponent("Notes.png") + try Self.png().write(to: good) + try Data("not a picture".utf8).write(to: bad) + #expect(await BackdropUserImages.isUsable(good)) + #expect(!(await BackdropUserImages.isUsable(bad))) + await #expect(throws: BackdropUserImages.ImportError.notMedia) { try await images.adoptChecked(bad) } + #expect(images.list().isEmpty, "a broken picture was listed") + let selection = try await images.adoptChecked(good) + #expect(images.list() == [selection]) + #expect(images.usage() == Int64(try Self.png().count)) + try images.remove(selection) + #expect(images.list().isEmpty && images.usage() == 0) + // Only the folder's own images can be removed. + #expect(throws: (any Error).self) { try images.remove(.file(path: good.path)) } + #expect(FileManager.default.fileExists(atPath: good.path)) + } + + @Test func aLinkMustBeHTTPS() async { + for text in ["http://example.com/sea.jpg", "file:///etc/hosts", "ftp://example.com/a.png"] { + await #expect(throws: BackdropUserImages.ImportError.notHTTPS) { _ = try await BackdropUserImages.download(URL(string: text)!) } + } + } + + @Test func aLinkIsNamedForItsBytesAndRefusedWhenItIsNotMedia() async throws { + let configuration = StubResponses.configuration() + StubResponses.set(["https://example.com/photo?id=7": .init(body: try Self.png(), type: "application/octet-stream"), + "https://example.com/page.png": .init(body: Data("".utf8), type: "text/html"), + "https://example.com/gone.png": .init(body: Data(), type: "text/plain", status: 404)]) + let file = try await BackdropUserImages.download(URL(string: "https://example.com/photo?id=7")!, configuration: configuration) + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + // PNG bytes behind a link with no extension: named for what they are. + #expect(file.lastPathComponent == "photo.png") + await #expect(throws: BackdropUserImages.ImportError.notMedia) { + _ = try await BackdropUserImages.download(URL(string: "https://example.com/page.png")!, configuration: configuration) + } + await #expect(throws: BackdropUserImages.ImportError.downloadFailed) { + _ = try await BackdropUserImages.download(URL(string: "https://example.com/gone.png")!, configuration: configuration) + } + } + + /// The review's path traversal (#21731): an encoded `../` in the link's name never leaves the + /// download's own folder. + @Test func aLinkNameCannotClimbOutOfTheDownloadFolder() async throws { + let configuration = StubResponses.configuration() + let link = "https://example.com/..%2F..%2Fevil.png" + StubResponses.set([link: .init(body: try Self.png(), type: "image/png")]) + let file = try await BackdropUserImages.download(URL(string: link)!, configuration: configuration) + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + #expect(file.lastPathComponent == "evil.png") + #expect(file.deletingLastPathComponent().lastPathComponent.hasPrefix("cmux-background-")) + #expect(BackdropUserImages.safeStem(URL(string: "https://example.com/...png")!) == "Background") + #expect(BackdropUserImages.safeStem(URL(string: "https://example.com/a%3Ab%5Cc.png")!) == "a-b-c") + } + + @Test func aLinkStopsAtItsCapAndNeverFollowsARedirectOffHTTPS() async throws { + let configuration = StubResponses.configuration() + let big = Data(count: Int(BackdropUserImages.maximumImageBytes) + 1) + StubResponses.set([ + // Refused from its declared length, before the body. + "https://example.com/declared.png": .init(body: Data(), type: "image/png", + headers: ["Content-Length": "\(BackdropUserImages.maximumImageBytes + 1)"]), + // No length declared: stopped once the count passes the cap for a picture. + "https://example.com/streamed.png": .init(body: big, type: "image/png", headers: ["Content-Length": ""]), + // Called a video, but a picture's bytes: a picture's cap, from the first chunk. + "https://example.com/clip.mp4": .init(body: Data([0x89, 0x50, 0x4E, 0x47]) + big, type: "video/mp4", + headers: ["Content-Length": ""]), + "https://example.com/hop.png": .init(body: Data(), type: "text/plain", status: 302, + headers: ["Location": "http://example.com/plain.png"]), + ]) + for link in ["https://example.com/declared.png", "https://example.com/streamed.png", "https://example.com/clip.mp4"] { + await #expect(throws: BackdropUserImages.ImportError.tooLarge) { + _ = try await BackdropUserImages.download(URL(string: link)!, configuration: configuration) + } + } + await #expect(throws: BackdropUserImages.ImportError.notHTTPS) { + _ = try await BackdropUserImages.download(URL(string: "https://example.com/hop.png")!, configuration: configuration) + } + } + + /// The review's crash (#21731): a download cancelled before its task exists ends quietly. + @Test func aDownloadCancelledBeforeItStartsEndsQuietly() async throws { + let configuration = StubResponses.configuration() + StubResponses.set(["https://example.com/sea.png": .init(body: try Self.png(), type: "image/png")]) + let task = Task { try await BackdropUserImages.download(URL(string: "https://example.com/sea.png")!, configuration: configuration) } + task.cancel() + let result = await task.result + if case .success(let file) = result { + // Cancelled too late to stop it: a whole file, cleaned up here. + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + } +} + +/// Canned answers by URL for a URLSession, so link downloads run without a network. +nonisolated final class StubResponses: URLProtocol, @unchecked Sendable { + struct Answer { + var body: Data + var type: String + var status = 200 + var headers: [String: String] = [:] + } + + nonisolated(unsafe) static var answers: [String: Answer] = [:] + static let lock = NSLock() + + static func set(_ answers: [String: Answer]) { lock.withLock { self.answers = answers } } + + static func configuration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubResponses.self] + return configuration + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url, let answer = Self.lock.withLock({ Self.answers[url.absoluteString] }) else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotFindHost)) + return + } + var fields = ["Content-Type": answer.type, "Content-Length": "\(answer.body.count)"].merging(answer.headers) { $1 } + fields = fields.filter { !$0.value.isEmpty } + let response = HTTPURLResponse(url: url, statusCode: answer.status, httpVersion: "HTTP/1.1", headerFields: fields)! + if (300..<400).contains(answer.status), let location = fields["Location"].flatMap(URL.init(string:)) { + client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: location), redirectResponse: response) + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + var offset = 0 + while offset < answer.body.count { + let end = min(offset + 1 << 20, answer.body.count) + client?.urlProtocol(self, didLoad: answer.body.subdata(in: offset.. = { + "cmux.settings.backdrop.not_https": "settingsWindow.backdropPicker.notHttps", + "cmux.settings.backdrop.not_media": "settingsWindow.backdropPicker.notMedia", + "cmux.settings.backdrop.too_large": "settingsWindow.backdropPicker.tooLarge", +}; + +/** Why a link could not be used, in the page's words. */ +export function linkRefusal(code: string): string { + return t(REFUSALS[code] ?? "settingsWindow.backdropPicker.downloadFailed"); +} + +export function BackdropLink({ onClose }: { onClose: () => void }) { + const store = useStore(); + const [link, setLink] = useState(""); + const [busy, setBusy] = useState(false); + const [refusal, setRefusal] = useState(null); + const add = async () => { + const text = link.trim(); + if (!text) return; + setBusy(true); + setRefusal(null); + const code = await store.addBackdropLink(text); + setBusy(false); + if (code === null) onClose(); + else setRefusal(linkRefusal(code)); + }; + return ( +
+
{ + event.preventDefault(); + void add(); + }} + > + setLink(event.target.value)} + /> + + +
+ {busy ? t("settingsWindow.artBrowser.downloading") : refusal} +
+ ); +} + +/** Bytes as the person reads them: megabytes, or gigabytes past a thousand of them. */ +export function formatBytes(bytes: number, locale?: string): string { + const giga = bytes >= 1e9; + return new Intl.NumberFormat(locale, { + style: "unit", + unit: giga ? "gigabyte" : "megabyte", + maximumFractionDigits: 1, + }).format(giga ? bytes / 1e9 : bytes / 1e6); +} diff --git a/webviews/src/pages/settings/components/HostCards.tsx b/webviews/src/pages/settings/components/HostCards.tsx index 7543b7891913..21e66d061762 100644 --- a/webviews/src/pages/settings/components/HostCards.tsx +++ b/webviews/src/pages/settings/components/HostCards.tsx @@ -7,9 +7,10 @@ import { useSettingsState, useStore } from "../context"; import { settingsFileName } from "../format"; import { rowsByKey } from "../schema"; import { Icon } from "../icons"; -import { t } from "../strings"; +import { locale, t } from "../strings"; import { ActionRow } from "./ActionRow"; import { ArtBrowser } from "./ArtBrowser"; +import { BackdropLink, formatBytes } from "./BackdropLink"; import { BackdropScopePicker, FOLLOW_ID, useWorkspaceBackground, WindowGlass, WorkspaceOpacity } from "./BackdropScope"; import { SettingRow } from "./SettingRow"; @@ -17,6 +18,8 @@ export const BACKGROUND_KEY = "appearance.background"; export const BACKGROUND_OPACITY_KEY = "appearance.backgroundOpacity"; /** Shuffle Daily (cx-t2x.5): one work of the art set a day. */ const DAILY_ID = "daily"; +/** The person's own images (BackdropUserImages.collection): removable, and shown in Finder. */ +const YOURS = "yours"; /** The rows the picker draws, left out of their group while it shows. */ export const BACKDROP_ROWS: ReadonlySet = new Set([BACKGROUND_KEY, BACKGROUND_OPACITY_KEY]); /** How long the pointer rests on a tile before the window shows it: sweeping across the grid @@ -47,6 +50,8 @@ export function Backdrops({ focus = null, workspace = null }: { focus?: string | const state = useSettingsState(); const { host, rows } = state; const [browsing, setBrowsing] = useState(false); + const [linking, setLinking] = useState(false); + const [usage, setUsage] = useState(null); const dwell = useRef | null>(null); // How to end the running preview: the app's, or a workspace's. const previewing = useRef<(() => void) | null>(null); @@ -84,6 +89,16 @@ export function Backdrops({ focus = null, workspace = null }: { focus?: string | const endOnLeave = useRef(endPreview); endOnLeave.current = endPreview; useEffect(() => () => endOnLeave.current(), []); + // What the person's own images take, read again whenever their number changes. + const ownCount = (host?.backdrops ?? []).filter((choice) => choice.collection === YOURS).length; + useEffect(() => { + if (ownCount === 0) return setUsage(null); + let live = true; + void store.backdropUsage().then((bytes) => live && setUsage(bytes)); + return () => { + live = false; + }; + }, [store, ownCount]); if (backdrops.length === 0) return null; const background = rows.get(BACKGROUND_KEY); const current = own @@ -222,18 +237,59 @@ export function Backdrops({ focus = null, workspace = null }: { focus?: string | {t("settingsWindow.artBrowser.open")} {t("settingsWindow.artBrowser.help")} + )} {browsing && !own && setBrowsing(false)} />} + {linking && !own && setLinking(false)} />} {groups.map((group) => (
{group.title &&

{group.title}

} + {group.key === YOURS && ( +
+ {usage !== null && ( + + {t("settingsWindow.backdropPicker.usage", formatBytes(usage, locale()))} + + )} + +
+ )}
{group.choices.map((choice) => - choice.collection === "apple" - ? tile(choice.id, choice.title, null, { pending: choice.downloaded === false }) - : tile(choice.id, choice.title, choice.attribution, choice), + choice.collection === "apple" ? ( + tile(choice.id, choice.title, null, { pending: choice.downloaded === false }) + ) : choice.collection === YOURS ? ( +
+ {tile(choice.id, choice.title, choice.attribution, choice)} + {/* The background in use stays: removing it would leave the window blank. */} + +
+ ) : ( + tile(choice.id, choice.title, choice.attribution, choice) + ), )}
diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index 03470d4c1361..ed12110df036 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -951,19 +951,29 @@ "settingsWindow.artBrowser.searching": "Searching…", "settingsWindow.backdropArt.credit": "Wheat Field with Cypresses · Vincent van Gogh · 1889 · The Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Painting attribution and source", + "settingsWindow.backdropPicker.add": "Add", "settingsWindow.backdropPicker.allWorkspaces": "All Workspaces", "settingsWindow.backdropPicker.chooseImage": "Choose Image…", + "settingsWindow.backdropPicker.downloadFailed": "That link could not be downloaded.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Download in System Settings", "settingsWindow.backdropPicker.dropHint": "Or drop an image anywhere on this page.", "settingsWindow.backdropPicker.followAll": "Same as All Workspaces", "settingsWindow.backdropPicker.followAllHelp": "Follows the background for all workspaces", + "settingsWindow.backdropPicker.fromLink": "Add from a Link", + "settingsWindow.backdropPicker.fromLinkHelp": "An image or video on the web.", + "settingsWindow.backdropPicker.linkField": "Link to an image or video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Use a link that starts with https://.", + "settingsWindow.backdropPicker.notMedia": "That is not an image or a video this Mac can play.", + "settingsWindow.backdropPicker.remove": "Remove %@", "settingsWindow.backdropPicker.scope": "Background for", "settingsWindow.backdropPicker.shuffleDaily": "Shuffle Daily", "settingsWindow.backdropPicker.shuffleDailyHelp": "A different painting every day.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "This Workspace (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "That file is too big: up to 50 MB for an image, 500 MB for a video.", + "settingsWindow.backdropPicker.usage": "Your images use %@.", "settingsWindow.backdropPicker.windowGlass": "Window Glass", "settingsWindow.backdropPicker.windowGlassHelp": "Show the desktop through the art.", "settingsWindow.backdropPicker.workspaceOpacity": "Opacity over Art", @@ -2034,19 +2044,29 @@ "settingsWindow.artBrowser.searching": "جارٍ البحث…", "settingsWindow.backdropArt.credit": "حقل قمح مع أشجار السرو · فنسنت فان غوخ · 1889 · متحف المتروبوليتان للفنون · CC0", "settingsWindow.backdropArt.source": "نسب اللوحة ومصدرها", + "settingsWindow.backdropPicker.add": "إضافة", "settingsWindow.backdropPicker.allWorkspaces": "كل مساحات العمل", "settingsWindow.backdropPicker.chooseImage": "اختيار صورة…", + "settingsWindow.backdropPicker.downloadFailed": "تعذّر تنزيل هذا الرابط.", "settingsWindow.backdropPicker.downloadInSystemSettings": "نزّلها في إعدادات النظام", "settingsWindow.backdropPicker.dropHint": "أو أفلت صورة في أي مكان على هذه الصفحة.", "settingsWindow.backdropPicker.followAll": "مثل كل مساحات العمل", "settingsWindow.backdropPicker.followAllHelp": "يتبع خلفية كل مساحات العمل", + "settingsWindow.backdropPicker.fromLink": "إضافة من رابط", + "settingsWindow.backdropPicker.fromLinkHelp": "صورة أو فيديو على الويب.", + "settingsWindow.backdropPicker.linkField": "رابط صورة أو فيديو", "settingsWindow.backdropPicker.none": "بلا خلفية", + "settingsWindow.backdropPicker.notHttps": "استخدم رابطًا يبدأ بـ https://.", + "settingsWindow.backdropPicker.notMedia": "هذا ليس صورة أو فيديو يمكن لهذا الـ Mac تشغيله.", + "settingsWindow.backdropPicker.remove": "إزالة %@", "settingsWindow.backdropPicker.scope": "الخلفية لـ", "settingsWindow.backdropPicker.shuffleDaily": "تبديل يومي", "settingsWindow.backdropPicker.shuffleDailyHelp": "لوحة مختلفة كل يوم.", "settingsWindow.backdropPicker.system": "خلفية نظام macOS", "settingsWindow.backdropPicker.thisWorkspace": "مساحة العمل هذه (%@)", "settingsWindow.backdropPicker.title": "خلفية الشاشة", + "settingsWindow.backdropPicker.tooLarge": "هذا الملف كبير جدًا: حتى 50 ميغابايت للصورة و500 ميغابايت للفيديو.", + "settingsWindow.backdropPicker.usage": "تستخدم صورك %@.", "settingsWindow.backdropPicker.windowGlass": "نافذة زجاجية", "settingsWindow.backdropPicker.windowGlassHelp": "يظهر سطح المكتب عبر اللوحة.", "settingsWindow.backdropPicker.workspaceOpacity": "العتامة فوق اللوحة", @@ -3117,19 +3137,29 @@ "settingsWindow.artBrowser.searching": "Pretraživanje…", "settingsWindow.backdropArt.credit": "Pšenično polje s čempresima · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Atribucija i izvor slike", + "settingsWindow.backdropPicker.add": "Dodaj", "settingsWindow.backdropPicker.allWorkspaces": "Svi radni prostori", "settingsWindow.backdropPicker.chooseImage": "Odaberi sliku…", + "settingsWindow.backdropPicker.downloadFailed": "Link se nije mogao preuzeti.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Preuzmi u Postavkama sistema", "settingsWindow.backdropPicker.dropHint": "Ili prevucite sliku bilo gdje na ovu stranicu.", "settingsWindow.backdropPicker.followAll": "Isto kao svi", "settingsWindow.backdropPicker.followAllHelp": "Prati pozadinu svih radnih prostora", + "settingsWindow.backdropPicker.fromLink": "Dodaj s linka", + "settingsWindow.backdropPicker.fromLinkHelp": "Slika ili video s weba.", + "settingsWindow.backdropPicker.linkField": "Link na sliku ili video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Koristite link koji počinje s https://.", + "settingsWindow.backdropPicker.notMedia": "To nije slika ni video koji ovaj Mac može reproducirati.", + "settingsWindow.backdropPicker.remove": "Ukloni %@", "settingsWindow.backdropPicker.scope": "Pozadina za", "settingsWindow.backdropPicker.shuffleDaily": "Svaki dan drugačije", "settingsWindow.backdropPicker.shuffleDailyHelp": "Druga slika svaki dan.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Ovaj radni prostor (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Datoteka je prevelika: do 50 MB za sliku, 500 MB za video.", + "settingsWindow.backdropPicker.usage": "Vaše slike zauzimaju %@.", "settingsWindow.backdropPicker.windowGlass": "Stakleni prozor", "settingsWindow.backdropPicker.windowGlassHelp": "Radna površina se vidi kroz sliku.", "settingsWindow.backdropPicker.workspaceOpacity": "Neprozirnost preko slike", @@ -4200,19 +4230,29 @@ "settingsWindow.artBrowser.searching": "Søger …", "settingsWindow.backdropArt.credit": "Hvedemark med cypresser · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Maleriets attribution og kilde", + "settingsWindow.backdropPicker.add": "Tilføj", "settingsWindow.backdropPicker.allWorkspaces": "Alle arbejdsområder", "settingsWindow.backdropPicker.chooseImage": "Vælg billede…", + "settingsWindow.backdropPicker.downloadFailed": "Linket kunne ikke hentes.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Download i Systemindstillinger", "settingsWindow.backdropPicker.dropHint": "Eller slip et billede hvor som helst på denne side.", "settingsWindow.backdropPicker.followAll": "Som alle arbejdsområder", "settingsWindow.backdropPicker.followAllHelp": "Følger baggrunden for alle arbejdsområder", + "settingsWindow.backdropPicker.fromLink": "Tilføj fra et link", + "settingsWindow.backdropPicker.fromLinkHelp": "Et billede eller en video på nettet.", + "settingsWindow.backdropPicker.linkField": "Link til et billede eller en video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Brug et link, der starter med https://.", + "settingsWindow.backdropPicker.notMedia": "Det er ikke et billede eller en video, som denne Mac kan afspille.", + "settingsWindow.backdropPicker.remove": "Fjern %@", "settingsWindow.backdropPicker.scope": "Baggrund for", "settingsWindow.backdropPicker.shuffleDaily": "Bland dagligt", "settingsWindow.backdropPicker.shuffleDailyHelp": "Et nyt maleri hver dag.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Dette arbejdsområde (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Filen er for stor: op til 50 MB for et billede, 500 MB for en video.", + "settingsWindow.backdropPicker.usage": "Dine billeder fylder %@.", "settingsWindow.backdropPicker.windowGlass": "Vinduesglas", "settingsWindow.backdropPicker.windowGlassHelp": "Skrivebordet ses gennem billedet.", "settingsWindow.backdropPicker.workspaceOpacity": "Uigennemsigtighed over billedet", @@ -5283,19 +5323,29 @@ "settingsWindow.artBrowser.searching": "Suche läuft …", "settingsWindow.backdropArt.credit": "Weizenfeld mit Zypressen · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Bildnachweis und Quelle", + "settingsWindow.backdropPicker.add": "Hinzufügen", "settingsWindow.backdropPicker.allWorkspaces": "Alle Workspaces", "settingsWindow.backdropPicker.chooseImage": "Bild auswählen …", + "settingsWindow.backdropPicker.downloadFailed": "Der Link konnte nicht geladen werden.", "settingsWindow.backdropPicker.downloadInSystemSettings": "In den Systemeinstellungen laden", "settingsWindow.backdropPicker.dropHint": "Oder ziehe ein Bild irgendwo auf diese Seite.", "settingsWindow.backdropPicker.followAll": "Wie alle Workspaces", "settingsWindow.backdropPicker.followAllHelp": "Folgt dem Hintergrund aller Workspaces", + "settingsWindow.backdropPicker.fromLink": "Über einen Link hinzufügen", + "settingsWindow.backdropPicker.fromLinkHelp": "Ein Bild oder Video aus dem Web.", + "settingsWindow.backdropPicker.linkField": "Link zu einem Bild oder Video", "settingsWindow.backdropPicker.none": "Kein Hintergrundbild", + "settingsWindow.backdropPicker.notHttps": "Verwende einen Link, der mit https:// beginnt.", + "settingsWindow.backdropPicker.notMedia": "Das ist kein Bild und kein Video, das dieser Mac abspielen kann.", + "settingsWindow.backdropPicker.remove": "%@ entfernen", "settingsWindow.backdropPicker.scope": "Hintergrund für", "settingsWindow.backdropPicker.shuffleDaily": "Täglich wechseln", "settingsWindow.backdropPicker.shuffleDailyHelp": "Jeden Tag ein anderes Gemälde.", "settingsWindow.backdropPicker.system": "macOS-Systembild", "settingsWindow.backdropPicker.thisWorkspace": "Dieser Workspace (%@)", "settingsWindow.backdropPicker.title": "Hintergrundbild", + "settingsWindow.backdropPicker.tooLarge": "Die Datei ist zu groß: bis zu 50 MB für ein Bild, 500 MB für ein Video.", + "settingsWindow.backdropPicker.usage": "Deine Bilder belegen %@.", "settingsWindow.backdropPicker.windowGlass": "Fensterglas", "settingsWindow.backdropPicker.windowGlassHelp": "Schreibtisch scheint durch das Bild.", "settingsWindow.backdropPicker.workspaceOpacity": "Deckkraft über dem Bild", @@ -6366,19 +6416,29 @@ "settingsWindow.artBrowser.searching": "Buscando…", "settingsWindow.backdropArt.credit": "Campo de trigo con cipreses · Vincent van Gogh · 1889 · Museo Metropolitano de Arte · CC0", "settingsWindow.backdropArt.source": "Atribución y fuente de la pintura", + "settingsWindow.backdropPicker.add": "Añadir", "settingsWindow.backdropPicker.allWorkspaces": "Todos los espacios de trabajo", "settingsWindow.backdropPicker.chooseImage": "Elegir imagen…", + "settingsWindow.backdropPicker.downloadFailed": "No se pudo descargar el enlace.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Descargar en Ajustes del Sistema", "settingsWindow.backdropPicker.dropHint": "O suelta una imagen en cualquier parte de esta página.", "settingsWindow.backdropPicker.followAll": "Igual que todos", "settingsWindow.backdropPicker.followAllHelp": "Sigue el fondo de todos los espacios de trabajo", + "settingsWindow.backdropPicker.fromLink": "Añadir desde un enlace", + "settingsWindow.backdropPicker.fromLinkHelp": "Una imagen o un vídeo de la web.", + "settingsWindow.backdropPicker.linkField": "Enlace a una imagen o un vídeo", "settingsWindow.backdropPicker.none": "Sin fondo", + "settingsWindow.backdropPicker.notHttps": "Usa un enlace que empiece por https://.", + "settingsWindow.backdropPicker.notMedia": "No es una imagen ni un vídeo que este Mac pueda reproducir.", + "settingsWindow.backdropPicker.remove": "Eliminar %@", "settingsWindow.backdropPicker.scope": "Fondo para", "settingsWindow.backdropPicker.shuffleDaily": "Cambiar a diario", "settingsWindow.backdropPicker.shuffleDailyHelp": "Una pintura distinta cada día.", "settingsWindow.backdropPicker.system": "Fondo de macOS", "settingsWindow.backdropPicker.thisWorkspace": "Este espacio de trabajo (%@)", "settingsWindow.backdropPicker.title": "Fondo de pantalla", + "settingsWindow.backdropPicker.tooLarge": "El archivo es demasiado grande: hasta 50 MB para una imagen y 500 MB para un vídeo.", + "settingsWindow.backdropPicker.usage": "Tus imágenes ocupan %@.", "settingsWindow.backdropPicker.windowGlass": "Ventana de vidrio", "settingsWindow.backdropPicker.windowGlassHelp": "El escritorio se ve a través de la obra.", "settingsWindow.backdropPicker.workspaceOpacity": "Opacidad sobre la obra", @@ -7449,19 +7509,29 @@ "settingsWindow.artBrowser.searching": "Recherche…", "settingsWindow.backdropArt.credit": "Champ de blé avec cyprès · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Crédit et source du tableau", + "settingsWindow.backdropPicker.add": "Ajouter", "settingsWindow.backdropPicker.allWorkspaces": "Tous les espaces de travail", "settingsWindow.backdropPicker.chooseImage": "Choisir une image…", + "settingsWindow.backdropPicker.downloadFailed": "Impossible de télécharger ce lien.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Télécharger dans Réglages Système", "settingsWindow.backdropPicker.dropHint": "Ou déposez une image n’importe où sur cette page.", "settingsWindow.backdropPicker.followAll": "Comme tous les espaces", "settingsWindow.backdropPicker.followAllHelp": "Suit le fond de tous les espaces de travail", + "settingsWindow.backdropPicker.fromLink": "Ajouter depuis un lien", + "settingsWindow.backdropPicker.fromLinkHelp": "Une image ou une vidéo du web.", + "settingsWindow.backdropPicker.linkField": "Lien vers une image ou une vidéo", "settingsWindow.backdropPicker.none": "Aucun fond d’écran", + "settingsWindow.backdropPicker.notHttps": "Utilisez un lien qui commence par https://.", + "settingsWindow.backdropPicker.notMedia": "Ce n’est ni une image ni une vidéo que ce Mac peut lire.", + "settingsWindow.backdropPicker.remove": "Supprimer %@", "settingsWindow.backdropPicker.scope": "Fond pour", "settingsWindow.backdropPicker.shuffleDaily": "Changer chaque jour", "settingsWindow.backdropPicker.shuffleDailyHelp": "Un tableau différent chaque jour.", "settingsWindow.backdropPicker.system": "Fond d’écran macOS", "settingsWindow.backdropPicker.thisWorkspace": "Cet espace de travail (%@)", "settingsWindow.backdropPicker.title": "Fond d’écran", + "settingsWindow.backdropPicker.tooLarge": "Ce fichier est trop volumineux : 50 Mo maximum pour une image, 500 Mo pour une vidéo.", + "settingsWindow.backdropPicker.usage": "Vos images occupent %@.", "settingsWindow.backdropPicker.windowGlass": "Fenêtre en verre", "settingsWindow.backdropPicker.windowGlassHelp": "Le bureau transparaît à travers l'œuvre.", "settingsWindow.backdropPicker.workspaceOpacity": "Opacité sur l'œuvre", @@ -8532,19 +8602,29 @@ "settingsWindow.artBrowser.searching": "Ricerca in corso…", "settingsWindow.backdropArt.credit": "Campo di grano con cipressi · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Attribuzione e fonte del dipinto", + "settingsWindow.backdropPicker.add": "Aggiungi", "settingsWindow.backdropPicker.allWorkspaces": "Tutti gli spazi di lavoro", "settingsWindow.backdropPicker.chooseImage": "Scegli immagine…", + "settingsWindow.backdropPicker.downloadFailed": "Impossibile scaricare il link.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Scarica in Impostazioni di Sistema", "settingsWindow.backdropPicker.dropHint": "Oppure trascina un’immagine su questa pagina.", "settingsWindow.backdropPicker.followAll": "Come tutti gli spazi", "settingsWindow.backdropPicker.followAllHelp": "Segue lo sfondo di tutti gli spazi di lavoro", + "settingsWindow.backdropPicker.fromLink": "Aggiungi da un link", + "settingsWindow.backdropPicker.fromLinkHelp": "Un’immagine o un video dal web.", + "settingsWindow.backdropPicker.linkField": "Link a un’immagine o a un video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Usa un link che inizi con https://.", + "settingsWindow.backdropPicker.notMedia": "Non è un’immagine né un video che questo Mac può riprodurre.", + "settingsWindow.backdropPicker.remove": "Rimuovi %@", "settingsWindow.backdropPicker.scope": "Sfondo per", "settingsWindow.backdropPicker.shuffleDaily": "Cambia ogni giorno", "settingsWindow.backdropPicker.shuffleDailyHelp": "Un dipinto diverso ogni giorno.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Questo spazio di lavoro (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Il file è troppo grande: fino a 50 MB per un’immagine, 500 MB per un video.", + "settingsWindow.backdropPicker.usage": "Le tue immagini occupano %@.", "settingsWindow.backdropPicker.windowGlass": "Finestra di vetro", "settingsWindow.backdropPicker.windowGlassHelp": "La scrivania si vede attraverso l'opera.", "settingsWindow.backdropPicker.workspaceOpacity": "Opacità sull'opera", @@ -9615,19 +9695,29 @@ "settingsWindow.artBrowser.searching": "検索中…", "settingsWindow.backdropArt.credit": "糸杉のある麦畑 · フィンセント・ファン・ゴッホ · 1889 · メトロポリタン美術館 · CC0", "settingsWindow.backdropArt.source": "絵画の作者情報と出典", + "settingsWindow.backdropPicker.add": "追加", "settingsWindow.backdropPicker.allWorkspaces": "すべてのワークスペース", "settingsWindow.backdropPicker.chooseImage": "画像を選択…", + "settingsWindow.backdropPicker.downloadFailed": "このリンクをダウンロードできませんでした。", "settingsWindow.backdropPicker.downloadInSystemSettings": "システム設定でダウンロード", "settingsWindow.backdropPicker.dropHint": "または、このページのどこかに画像をドロップします。", "settingsWindow.backdropPicker.followAll": "すべてと同じ", "settingsWindow.backdropPicker.followAllHelp": "すべてのワークスペースの背景に従います", + "settingsWindow.backdropPicker.fromLink": "リンクから追加", + "settingsWindow.backdropPicker.fromLinkHelp": "Web 上の画像または動画。", + "settingsWindow.backdropPicker.linkField": "画像または動画へのリンク", "settingsWindow.backdropPicker.none": "壁紙なし", + "settingsWindow.backdropPicker.notHttps": "https:// で始まるリンクを使ってください。", + "settingsWindow.backdropPicker.notMedia": "この Mac で再生できる画像または動画ではありません。", + "settingsWindow.backdropPicker.remove": "%@ を削除", "settingsWindow.backdropPicker.scope": "背景の適用先", "settingsWindow.backdropPicker.shuffleDaily": "毎日シャッフル", "settingsWindow.backdropPicker.shuffleDailyHelp": "毎日ちがう絵画を表示します。", "settingsWindow.backdropPicker.system": "macOS システム壁紙", "settingsWindow.backdropPicker.thisWorkspace": "このワークスペース(%@)", "settingsWindow.backdropPicker.title": "壁紙", + "settingsWindow.backdropPicker.tooLarge": "ファイルが大きすぎます。画像は 50 MB、動画は 500 MB までです。", + "settingsWindow.backdropPicker.usage": "自分の画像の使用量: %@", "settingsWindow.backdropPicker.windowGlass": "ウインドウガラス", "settingsWindow.backdropPicker.windowGlassHelp": "アート越しにデスクトップが見えます。", "settingsWindow.backdropPicker.workspaceOpacity": "アートの上の不透明度", @@ -10698,19 +10788,29 @@ "settingsWindow.artBrowser.searching": "កំពុងស្វែងរក…", "settingsWindow.backdropArt.credit": "វាលស្រូវសាលីជាមួយដើមស៊ីព្រេស · Vincent van Gogh · 1889 · សារមន្ទីរសិល្បៈ Metropolitan · CC0", "settingsWindow.backdropArt.source": "ការបញ្ជាក់ប្រភព និងប្រភពគំនូរ", + "settingsWindow.backdropPicker.add": "បន្ថែម", "settingsWindow.backdropPicker.allWorkspaces": "កន្លែងធ្វើការទាំងអស់", "settingsWindow.backdropPicker.chooseImage": "ជ្រើសរើសរូបភាព…", + "settingsWindow.backdropPicker.downloadFailed": "មិនអាចទាញយកតំណនេះបានទេ។", "settingsWindow.backdropPicker.downloadInSystemSettings": "ទាញយកនៅក្នុង System Settings", "settingsWindow.backdropPicker.dropHint": "ឬទម្លាក់រូបភាពនៅកន្លែងណាមួយលើទំព័រនេះ។", "settingsWindow.backdropPicker.followAll": "ដូចកន្លែងធ្វើការទាំងអស់", "settingsWindow.backdropPicker.followAllHelp": "ធ្វើតាមផ្ទៃខាងក្រោយនៃកន្លែងធ្វើការទាំងអស់", + "settingsWindow.backdropPicker.fromLink": "បន្ថែមពីតំណ", + "settingsWindow.backdropPicker.fromLinkHelp": "រូបភាព ឬវីដេអូនៅលើបណ្ដាញ។", + "settingsWindow.backdropPicker.linkField": "តំណទៅរូបភាព ឬវីដេអូ", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "សូមប្រើតំណដែលចាប់ផ្ដើមដោយ https://។", + "settingsWindow.backdropPicker.notMedia": "នេះមិនមែនជារូបភាព ឬវីដេអូដែល Mac នេះអាចចាក់បានទេ។", + "settingsWindow.backdropPicker.remove": "លុប %@", "settingsWindow.backdropPicker.scope": "ផ្ទៃខាងក្រោយសម្រាប់", "settingsWindow.backdropPicker.shuffleDaily": "ប្ដូររាល់ថ្ងៃ", "settingsWindow.backdropPicker.shuffleDailyHelp": "គំនូរខុសគ្នារៀងរាល់ថ្ងៃ។", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "កន្លែងធ្វើការនេះ (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "ឯកសារនេះធំពេក៖ រហូតដល់ 50 MB សម្រាប់រូបភាព និង 500 MB សម្រាប់វីដេអូ។", + "settingsWindow.backdropPicker.usage": "រូបភាពរបស់អ្នកប្រើ %@។", "settingsWindow.backdropPicker.windowGlass": "កញ្ចក់បង្អួច", "settingsWindow.backdropPicker.windowGlassHelp": "មើលឃើញផ្ទៃតុតាមរយៈរូបភាព។", "settingsWindow.backdropPicker.workspaceOpacity": "ភាពស្រអាប់លើរូបភាព", @@ -11781,19 +11881,29 @@ "settingsWindow.artBrowser.searching": "검색 중…", "settingsWindow.backdropArt.credit": "사이프러스가 있는 밀밭 · 빈센트 반 고흐 · 1889 · 메트로폴리탄 미술관 · CC0", "settingsWindow.backdropArt.source": "그림 저작자와 출처", + "settingsWindow.backdropPicker.add": "추가", "settingsWindow.backdropPicker.allWorkspaces": "모든 작업 공간", "settingsWindow.backdropPicker.chooseImage": "이미지 선택…", + "settingsWindow.backdropPicker.downloadFailed": "이 링크를 다운로드할 수 없습니다.", "settingsWindow.backdropPicker.downloadInSystemSettings": "시스템 설정에서 다운로드", "settingsWindow.backdropPicker.dropHint": "또는 이 페이지 아무 곳에나 이미지를 놓으세요.", "settingsWindow.backdropPicker.followAll": "모든 작업 공간과 동일", "settingsWindow.backdropPicker.followAllHelp": "모든 작업 공간의 배경을 따릅니다", + "settingsWindow.backdropPicker.fromLink": "링크로 추가", + "settingsWindow.backdropPicker.fromLinkHelp": "웹에 있는 이미지나 동영상.", + "settingsWindow.backdropPicker.linkField": "이미지 또는 동영상 링크", "settingsWindow.backdropPicker.none": "배경화면 없음", + "settingsWindow.backdropPicker.notHttps": "https://로 시작하는 링크를 사용하세요.", + "settingsWindow.backdropPicker.notMedia": "이 Mac에서 재생할 수 있는 이미지나 동영상이 아닙니다.", + "settingsWindow.backdropPicker.remove": "%@ 제거", "settingsWindow.backdropPicker.scope": "배경 적용 대상", "settingsWindow.backdropPicker.shuffleDaily": "매일 바꾸기", "settingsWindow.backdropPicker.shuffleDailyHelp": "매일 다른 그림을 보여 줍니다.", "settingsWindow.backdropPicker.system": "macOS 시스템 배경화면", "settingsWindow.backdropPicker.thisWorkspace": "이 작업 공간(%@)", "settingsWindow.backdropPicker.title": "배경화면", + "settingsWindow.backdropPicker.tooLarge": "파일이 너무 큽니다. 이미지는 50MB, 동영상은 500MB까지입니다.", + "settingsWindow.backdropPicker.usage": "내 이미지가 %@ 사용 중입니다.", "settingsWindow.backdropPicker.windowGlass": "창 유리", "settingsWindow.backdropPicker.windowGlassHelp": "작품 너머로 데스크탑이 보입니다.", "settingsWindow.backdropPicker.workspaceOpacity": "작품 위 불투명도", @@ -12864,19 +12974,29 @@ "settingsWindow.artBrowser.searching": "Søker …", "settingsWindow.backdropArt.credit": "Hveteåker med sypresser · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Kreditering og kilde for maleriet", + "settingsWindow.backdropPicker.add": "Legg til", "settingsWindow.backdropPicker.allWorkspaces": "Alle arbeidsområder", "settingsWindow.backdropPicker.chooseImage": "Velg bilde …", + "settingsWindow.backdropPicker.downloadFailed": "Lenken kunne ikke lastes ned.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Last ned i Systeminnstillinger", "settingsWindow.backdropPicker.dropHint": "Eller slipp et bilde hvor som helst på denne siden.", "settingsWindow.backdropPicker.followAll": "Som alle arbeidsområder", "settingsWindow.backdropPicker.followAllHelp": "Følger bakgrunnen for alle arbeidsområder", + "settingsWindow.backdropPicker.fromLink": "Legg til fra en lenke", + "settingsWindow.backdropPicker.fromLinkHelp": "Et bilde eller en video på nettet.", + "settingsWindow.backdropPicker.linkField": "Lenke til et bilde eller en video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Bruk en lenke som starter med https://.", + "settingsWindow.backdropPicker.notMedia": "Dette er ikke et bilde eller en video denne Macen kan spille av.", + "settingsWindow.backdropPicker.remove": "Fjern %@", "settingsWindow.backdropPicker.scope": "Bakgrunn for", "settingsWindow.backdropPicker.shuffleDaily": "Bytt daglig", "settingsWindow.backdropPicker.shuffleDailyHelp": "Et nytt maleri hver dag.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Dette arbeidsområdet (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Filen er for stor: opptil 50 MB for et bilde, 500 MB for en video.", + "settingsWindow.backdropPicker.usage": "Bildene dine bruker %@.", "settingsWindow.backdropPicker.windowGlass": "Vindusglass", "settingsWindow.backdropPicker.windowGlassHelp": "Skrivebordet synes gjennom bildet.", "settingsWindow.backdropPicker.workspaceOpacity": "Dekkevne over bildet", @@ -13947,19 +14067,29 @@ "settingsWindow.artBrowser.searching": "Wyszukiwanie…", "settingsWindow.backdropArt.credit": "Pole pszenicy z cyprysami · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Informacje o autorstwie i źródle obrazu", + "settingsWindow.backdropPicker.add": "Dodaj", "settingsWindow.backdropPicker.allWorkspaces": "Wszystkie obszary robocze", "settingsWindow.backdropPicker.chooseImage": "Wybierz obraz…", + "settingsWindow.backdropPicker.downloadFailed": "Nie udało się pobrać tego linku.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Pobierz w Ustawieniach systemowych", "settingsWindow.backdropPicker.dropHint": "Albo upuść obraz w dowolnym miejscu tej strony.", "settingsWindow.backdropPicker.followAll": "Jak wszystkie", "settingsWindow.backdropPicker.followAllHelp": "Używa tła wszystkich obszarów roboczych", + "settingsWindow.backdropPicker.fromLink": "Dodaj z linku", + "settingsWindow.backdropPicker.fromLinkHelp": "Obraz lub wideo z internetu.", + "settingsWindow.backdropPicker.linkField": "Link do obrazu lub wideo", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Użyj linku zaczynającego się od https://.", + "settingsWindow.backdropPicker.notMedia": "To nie jest obraz ani wideo, które ten Mac może odtworzyć.", + "settingsWindow.backdropPicker.remove": "Usuń %@", "settingsWindow.backdropPicker.scope": "Tło dla", "settingsWindow.backdropPicker.shuffleDaily": "Zmieniaj codziennie", "settingsWindow.backdropPicker.shuffleDailyHelp": "Codziennie inny obraz.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Ten obszar roboczy (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Plik jest za duży: do 50 MB dla obrazu, 500 MB dla wideo.", + "settingsWindow.backdropPicker.usage": "Twoje obrazy zajmują %@.", "settingsWindow.backdropPicker.windowGlass": "Szklane okno", "settingsWindow.backdropPicker.windowGlassHelp": "Biurko prześwituje przez obraz.", "settingsWindow.backdropPicker.workspaceOpacity": "Krycie nad obrazem", @@ -15030,19 +15160,29 @@ "settingsWindow.artBrowser.searching": "Buscando…", "settingsWindow.backdropArt.credit": "Campo de trigo com ciprestes · Vincent van Gogh · 1889 · Metropolitan Museum of Art · CC0", "settingsWindow.backdropArt.source": "Atribuição e fonte da pintura", + "settingsWindow.backdropPicker.add": "Adicionar", "settingsWindow.backdropPicker.allWorkspaces": "Todos os espaços de trabalho", "settingsWindow.backdropPicker.chooseImage": "Escolher Imagem…", + "settingsWindow.backdropPicker.downloadFailed": "Não foi possível baixar o link.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Baixar nos Ajustes do Sistema", "settingsWindow.backdropPicker.dropHint": "Ou solte uma imagem em qualquer lugar desta página.", "settingsWindow.backdropPicker.followAll": "Igual a todos", "settingsWindow.backdropPicker.followAllHelp": "Segue o fundo de todos os espaços de trabalho", + "settingsWindow.backdropPicker.fromLink": "Adicionar de um link", + "settingsWindow.backdropPicker.fromLinkHelp": "Uma imagem ou vídeo da web.", + "settingsWindow.backdropPicker.linkField": "Link para uma imagem ou vídeo", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Use um link que comece com https://.", + "settingsWindow.backdropPicker.notMedia": "Isso não é uma imagem nem um vídeo que este Mac possa reproduzir.", + "settingsWindow.backdropPicker.remove": "Remover %@", "settingsWindow.backdropPicker.scope": "Fundo para", "settingsWindow.backdropPicker.shuffleDaily": "Trocar diariamente", "settingsWindow.backdropPicker.shuffleDailyHelp": "Uma pintura diferente a cada dia.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Este espaço de trabalho (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "O arquivo é grande demais: até 50 MB para imagem e 500 MB para vídeo.", + "settingsWindow.backdropPicker.usage": "Suas imagens usam %@.", "settingsWindow.backdropPicker.windowGlass": "Janela de Vidro", "settingsWindow.backdropPicker.windowGlassHelp": "A mesa aparece através da obra.", "settingsWindow.backdropPicker.workspaceOpacity": "Opacidade sobre a obra", @@ -16113,19 +16253,29 @@ "settingsWindow.artBrowser.searching": "Поиск…", "settingsWindow.backdropArt.credit": "Пшеничное поле с кипарисами · Винсент Ван Гог · 1889 · Метрополитен-музей · CC0", "settingsWindow.backdropArt.source": "Атрибуция и источник картины", + "settingsWindow.backdropPicker.add": "Добавить", "settingsWindow.backdropPicker.allWorkspaces": "Все рабочие области", "settingsWindow.backdropPicker.chooseImage": "Выбрать изображение…", + "settingsWindow.backdropPicker.downloadFailed": "Не удалось загрузить по этой ссылке.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Загрузить в Системных настройках", "settingsWindow.backdropPicker.dropHint": "Или перетащите изображение в любое место этой страницы.", "settingsWindow.backdropPicker.followAll": "Как у всех", "settingsWindow.backdropPicker.followAllHelp": "Следует фону всех рабочих областей", + "settingsWindow.backdropPicker.fromLink": "Добавить по ссылке", + "settingsWindow.backdropPicker.fromLinkHelp": "Изображение или видео из интернета.", + "settingsWindow.backdropPicker.linkField": "Ссылка на изображение или видео", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Используйте ссылку, которая начинается с https://.", + "settingsWindow.backdropPicker.notMedia": "Это не изображение и не видео, которое этот Mac может воспроизвести.", + "settingsWindow.backdropPicker.remove": "Удалить %@", "settingsWindow.backdropPicker.scope": "Фон для", "settingsWindow.backdropPicker.shuffleDaily": "Менять ежедневно", "settingsWindow.backdropPicker.shuffleDailyHelp": "Каждый день другая картина.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Эта рабочая область (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Файл слишком большой: до 50 МБ для изображения и до 500 МБ для видео.", + "settingsWindow.backdropPicker.usage": "Ваши изображения занимают %@.", "settingsWindow.backdropPicker.windowGlass": "Стеклянное окно", "settingsWindow.backdropPicker.windowGlassHelp": "Рабочий стол виден сквозь картину.", "settingsWindow.backdropPicker.workspaceOpacity": "Непрозрачность над картиной", @@ -17196,19 +17346,29 @@ "settingsWindow.artBrowser.searching": "กำลังค้นหา…", "settingsWindow.backdropArt.credit": "ทุ่งข้าวสาลีกับต้นไซเปรส · Vincent van Gogh · 1889 · พิพิธภัณฑ์ศิลปะเมโทรโพลิแทน · CC0", "settingsWindow.backdropArt.source": "การแสดงที่มาและแหล่งที่มาของภาพวาด", + "settingsWindow.backdropPicker.add": "เพิ่ม", "settingsWindow.backdropPicker.allWorkspaces": "เวิร์กสเปซทั้งหมด", "settingsWindow.backdropPicker.chooseImage": "เลือกรูปภาพ…", + "settingsWindow.backdropPicker.downloadFailed": "ดาวน์โหลดลิงก์นี้ไม่ได้", "settingsWindow.backdropPicker.downloadInSystemSettings": "ดาวน์โหลดในการตั้งค่าระบบ", "settingsWindow.backdropPicker.dropHint": "หรือวางรูปภาพที่ใดก็ได้บนหน้านี้", "settingsWindow.backdropPicker.followAll": "เหมือนเวิร์กสเปซทั้งหมด", "settingsWindow.backdropPicker.followAllHelp": "ใช้พื้นหลังเดียวกับเวิร์กสเปซทั้งหมด", + "settingsWindow.backdropPicker.fromLink": "เพิ่มจากลิงก์", + "settingsWindow.backdropPicker.fromLinkHelp": "รูปภาพหรือวิดีโอบนเว็บ", + "settingsWindow.backdropPicker.linkField": "ลิงก์ไปยังรูปภาพหรือวิดีโอ", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "ใช้ลิงก์ที่ขึ้นต้นด้วย https://", + "settingsWindow.backdropPicker.notMedia": "นี่ไม่ใช่รูปภาพหรือวิดีโอที่ Mac เครื่องนี้เล่นได้", + "settingsWindow.backdropPicker.remove": "ลบ %@", "settingsWindow.backdropPicker.scope": "พื้นหลังสำหรับ", "settingsWindow.backdropPicker.shuffleDaily": "สลับทุกวัน", "settingsWindow.backdropPicker.shuffleDailyHelp": "ภาพวาดใหม่ทุกวัน", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "เวิร์กสเปซนี้ (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "ไฟล์ใหญ่เกินไป: รูปภาพได้ถึง 50 MB และวิดีโอได้ถึง 500 MB", + "settingsWindow.backdropPicker.usage": "รูปภาพของคุณใช้พื้นที่ %@", "settingsWindow.backdropPicker.windowGlass": "หน้าต่างกระจก", "settingsWindow.backdropPicker.windowGlassHelp": "มองเห็นเดสก์ท็อปผ่านภาพ", "settingsWindow.backdropPicker.workspaceOpacity": "ความทึบเหนือภาพ", @@ -18279,19 +18439,29 @@ "settingsWindow.artBrowser.searching": "Aranıyor…", "settingsWindow.backdropArt.credit": "Servilerle buğday tarlası · Vincent van Gogh · 1889 · Metropolitan Sanat Müzesi · CC0", "settingsWindow.backdropArt.source": "Tablo bilgileri ve kaynağı", + "settingsWindow.backdropPicker.add": "Ekle", "settingsWindow.backdropPicker.allWorkspaces": "Tüm Çalışma Alanları", "settingsWindow.backdropPicker.chooseImage": "Görsel Seç…", + "settingsWindow.backdropPicker.downloadFailed": "Bu bağlantı indirilemedi.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Sistem Ayarları’nda indirin", "settingsWindow.backdropPicker.dropHint": "Ya da bu sayfada herhangi bir yere bir görsel bırakın.", "settingsWindow.backdropPicker.followAll": "Tümüyle Aynı", "settingsWindow.backdropPicker.followAllHelp": "Tüm çalışma alanlarının arka planını izler", + "settingsWindow.backdropPicker.fromLink": "Bağlantıdan Ekle", + "settingsWindow.backdropPicker.fromLinkHelp": "Web’deki bir görsel veya video.", + "settingsWindow.backdropPicker.linkField": "Görsel veya video bağlantısı", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "https:// ile başlayan bir bağlantı kullanın.", + "settingsWindow.backdropPicker.notMedia": "Bu, bu Mac’in oynatabileceği bir görsel veya video değil.", + "settingsWindow.backdropPicker.remove": "%@ öğesini kaldır", "settingsWindow.backdropPicker.scope": "Arka plan:", "settingsWindow.backdropPicker.shuffleDaily": "Her gün değiştir", "settingsWindow.backdropPicker.shuffleDailyHelp": "Her gün farklı bir tablo.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Bu Çalışma Alanı (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Dosya çok büyük: görsel için en fazla 50 MB, video için 500 MB.", + "settingsWindow.backdropPicker.usage": "Görselleriniz %@ yer kaplıyor.", "settingsWindow.backdropPicker.windowGlass": "Pencere Camı", "settingsWindow.backdropPicker.windowGlassHelp": "Masaüstü eserin ardından görünür.", "settingsWindow.backdropPicker.workspaceOpacity": "Eser Üzerinde Opaklık", @@ -19362,19 +19532,29 @@ "settingsWindow.artBrowser.searching": "Пошук…", "settingsWindow.backdropArt.credit": "Пшеничне поле з кипарисами · Вінсент ван Гог · 1889 · Музей мистецтва Метрополітен · CC0", "settingsWindow.backdropArt.source": "Атрибуція та джерело картини", + "settingsWindow.backdropPicker.add": "Додати", "settingsWindow.backdropPicker.allWorkspaces": "Усі робочі простори", "settingsWindow.backdropPicker.chooseImage": "Вибрати зображення…", + "settingsWindow.backdropPicker.downloadFailed": "Не вдалося завантажити за цим посиланням.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Завантажити в Системних параметрах", "settingsWindow.backdropPicker.dropHint": "Або перетягніть зображення будь-куди на цій сторінці.", "settingsWindow.backdropPicker.followAll": "Як для всіх", "settingsWindow.backdropPicker.followAllHelp": "Використовує тло всіх робочих просторів", + "settingsWindow.backdropPicker.fromLink": "Додати за посиланням", + "settingsWindow.backdropPicker.fromLinkHelp": "Зображення або відео з інтернету.", + "settingsWindow.backdropPicker.linkField": "Посилання на зображення або відео", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Використайте посилання, що починається з https://.", + "settingsWindow.backdropPicker.notMedia": "Це не зображення і не відео, яке цей Mac може відтворити.", + "settingsWindow.backdropPicker.remove": "Вилучити %@", "settingsWindow.backdropPicker.scope": "Тло для", "settingsWindow.backdropPicker.shuffleDaily": "Змінювати щодня", "settingsWindow.backdropPicker.shuffleDailyHelp": "Щодня інша картина.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Цей робочий простір (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Файл завеликий: до 50 МБ для зображення, 500 МБ для відео.", + "settingsWindow.backdropPicker.usage": "Ваші зображення займають %@.", "settingsWindow.backdropPicker.windowGlass": "Скляне вікно", "settingsWindow.backdropPicker.windowGlassHelp": "Робочий стіл видно крізь картину.", "settingsWindow.backdropPicker.workspaceOpacity": "Непрозорість над картиною", @@ -20445,19 +20625,29 @@ "settingsWindow.artBrowser.searching": "Đang tìm…", "settingsWindow.backdropArt.credit": "Cánh đồng lúa mì với những cây bách · Vincent van Gogh · 1889 · Bảo tàng Nghệ thuật Metropolitan · CC0", "settingsWindow.backdropArt.source": "Ghi công và nguồn của bức tranh", + "settingsWindow.backdropPicker.add": "Thêm", "settingsWindow.backdropPicker.allWorkspaces": "Tất cả không gian làm việc", "settingsWindow.backdropPicker.chooseImage": "Chọn hình ảnh…", + "settingsWindow.backdropPicker.downloadFailed": "Không tải xuống được liên kết này.", "settingsWindow.backdropPicker.downloadInSystemSettings": "Tải về trong Cài đặt hệ thống", "settingsWindow.backdropPicker.dropHint": "Hoặc thả một hình ảnh vào bất kỳ đâu trên trang này.", "settingsWindow.backdropPicker.followAll": "Giống tất cả", "settingsWindow.backdropPicker.followAllHelp": "Theo hình nền của tất cả không gian làm việc", + "settingsWindow.backdropPicker.fromLink": "Thêm từ liên kết", + "settingsWindow.backdropPicker.fromLinkHelp": "Hình ảnh hoặc video trên web.", + "settingsWindow.backdropPicker.linkField": "Liên kết đến hình ảnh hoặc video", "settingsWindow.backdropPicker.none": "No wallpaper", + "settingsWindow.backdropPicker.notHttps": "Hãy dùng liên kết bắt đầu bằng https://.", + "settingsWindow.backdropPicker.notMedia": "Đây không phải hình ảnh hay video mà máy Mac này phát được.", + "settingsWindow.backdropPicker.remove": "Xóa %@", "settingsWindow.backdropPicker.scope": "Hình nền cho", "settingsWindow.backdropPicker.shuffleDaily": "Đổi mỗi ngày", "settingsWindow.backdropPicker.shuffleDailyHelp": "Mỗi ngày một bức tranh khác.", "settingsWindow.backdropPicker.system": "macOS system wallpaper", "settingsWindow.backdropPicker.thisWorkspace": "Không gian làm việc này (%@)", "settingsWindow.backdropPicker.title": "Wallpaper", + "settingsWindow.backdropPicker.tooLarge": "Tệp quá lớn: tối đa 50 MB cho hình ảnh, 500 MB cho video.", + "settingsWindow.backdropPicker.usage": "Hình ảnh của bạn dùng %@.", "settingsWindow.backdropPicker.windowGlass": "Cửa sổ kính", "settingsWindow.backdropPicker.windowGlassHelp": "Nhìn thấy màn hình nền qua tranh.", "settingsWindow.backdropPicker.workspaceOpacity": "Độ mờ trên tranh", @@ -21528,19 +21718,29 @@ "settingsWindow.artBrowser.searching": "正在搜索…", "settingsWindow.backdropArt.credit": "有柏树的麦田 · 文森特·梵高 · 1889 · 大都会艺术博物馆 · CC0", "settingsWindow.backdropArt.source": "画作信息与来源", + "settingsWindow.backdropPicker.add": "添加", "settingsWindow.backdropPicker.allWorkspaces": "所有工作区", "settingsWindow.backdropPicker.chooseImage": "选择图片…", + "settingsWindow.backdropPicker.downloadFailed": "无法下载此链接。", "settingsWindow.backdropPicker.downloadInSystemSettings": "在系统设置中下载", "settingsWindow.backdropPicker.dropHint": "或将图片拖放到此页面的任意位置。", "settingsWindow.backdropPicker.followAll": "与所有工作区相同", "settingsWindow.backdropPicker.followAllHelp": "跟随所有工作区的背景", + "settingsWindow.backdropPicker.fromLink": "从链接添加", + "settingsWindow.backdropPicker.fromLinkHelp": "网络上的图片或视频。", + "settingsWindow.backdropPicker.linkField": "图片或视频链接", "settingsWindow.backdropPicker.none": "无墙纸", + "settingsWindow.backdropPicker.notHttps": "请使用以 https:// 开头的链接。", + "settingsWindow.backdropPicker.notMedia": "这不是此 Mac 能播放的图片或视频。", + "settingsWindow.backdropPicker.remove": "移除 %@", "settingsWindow.backdropPicker.scope": "背景应用于", "settingsWindow.backdropPicker.shuffleDaily": "每日轮换", "settingsWindow.backdropPicker.shuffleDailyHelp": "每天显示一幅不同的画作。", "settingsWindow.backdropPicker.system": "macOS 系统墙纸", "settingsWindow.backdropPicker.thisWorkspace": "此工作区(%@)", "settingsWindow.backdropPicker.title": "墙纸", + "settingsWindow.backdropPicker.tooLarge": "文件太大:图片最多 50 MB,视频最多 500 MB。", + "settingsWindow.backdropPicker.usage": "你的图片占用 %@。", "settingsWindow.backdropPicker.windowGlass": "窗口玻璃", "settingsWindow.backdropPicker.windowGlassHelp": "透过作品看到桌面。", "settingsWindow.backdropPicker.workspaceOpacity": "作品上方不透明度", @@ -22611,19 +22811,29 @@ "settingsWindow.artBrowser.searching": "正在搜尋…", "settingsWindow.backdropArt.credit": "有絲柏的麥田 · 文森·梵谷 · 1889 · 大都會藝術博物館 · CC0", "settingsWindow.backdropArt.source": "畫作資訊與來源", + "settingsWindow.backdropPicker.add": "加入", "settingsWindow.backdropPicker.allWorkspaces": "所有工作區", "settingsWindow.backdropPicker.chooseImage": "選擇圖片…", + "settingsWindow.backdropPicker.downloadFailed": "無法下載此連結。", "settingsWindow.backdropPicker.downloadInSystemSettings": "在系統設定中下載", "settingsWindow.backdropPicker.dropHint": "或將圖片拖放到此頁面的任何位置。", "settingsWindow.backdropPicker.followAll": "與所有工作區相同", "settingsWindow.backdropPicker.followAllHelp": "跟隨所有工作區的背景", + "settingsWindow.backdropPicker.fromLink": "從連結加入", + "settingsWindow.backdropPicker.fromLinkHelp": "網路上的圖片或影片。", + "settingsWindow.backdropPicker.linkField": "圖片或影片連結", "settingsWindow.backdropPicker.none": "沒有桌布", + "settingsWindow.backdropPicker.notHttps": "請使用以 https:// 開頭的連結。", + "settingsWindow.backdropPicker.notMedia": "這不是此 Mac 能播放的圖片或影片。", + "settingsWindow.backdropPicker.remove": "移除 %@", "settingsWindow.backdropPicker.scope": "背景套用於", "settingsWindow.backdropPicker.shuffleDaily": "每日輪換", "settingsWindow.backdropPicker.shuffleDailyHelp": "每天顯示一幅不同的畫作。", "settingsWindow.backdropPicker.system": "macOS 系統桌布", "settingsWindow.backdropPicker.thisWorkspace": "此工作區(%@)", "settingsWindow.backdropPicker.title": "桌布", + "settingsWindow.backdropPicker.tooLarge": "檔案太大:圖片最多 50 MB,影片最多 500 MB。", + "settingsWindow.backdropPicker.usage": "你的圖片佔用 %@。", "settingsWindow.backdropPicker.windowGlass": "視窗玻璃", "settingsWindow.backdropPicker.windowGlassHelp": "透過作品看到桌面。", "settingsWindow.backdropPicker.workspaceOpacity": "作品上方不透明度", diff --git a/webviews/src/pages/settings/hostCards.test.tsx b/webviews/src/pages/settings/hostCards.test.tsx index 12ece36631da..e56fcb963883 100644 --- a/webviews/src/pages/settings/hostCards.test.tsx +++ b/webviews/src/pages/settings/hostCards.test.tsx @@ -294,3 +294,66 @@ test("Advanced names the loaded settings file and shows one Reload Configuration expect(text).not.toContain("cmux-next.json"); page.unmount(); }); + +test("Add from a Link downloads an image and sets it; a refused link says why; your images can be removed", async () => { + const page = await renderPage({ path: "/settings/appearance" }); + const picker = () => page.container.querySelector('[data-card="backdrop"]')!; + const linkTile = [...picker().querySelectorAll("button.backdrop-tile")].find((button) => + button.textContent?.includes("Add from a Link"), + )!; + await act(async () => linkTile.click()); + await settle(); + const form = () => page.container.querySelector('[data-card="backdrop-link"]'); + const field = form()!.querySelector('input[type="url"]')!; + const type = async (text: string) => { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(field, text); + field.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + }; + const submit = async () => { + await act(async () => form()!.querySelector('button[type="submit"]')!.click()); + await settle(); + }; + + await type("http://example.com/sea.jpg"); + await submit(); + expect(form()!.querySelector("output")!.textContent).toBe("Use a link that starts with https://."); + await type("https://example.com/notes.txt"); + await submit(); + expect(form()!.querySelector("output")!.textContent).toBe("That is not an image or a video this Mac can play."); + + await type("https://example.com/sea.jpg"); + await submit(); + expect(runs(page, "cmux.settings.backdrop.image.link").at(-1)).toMatchObject({ url: "https://example.com/sea.jpg" }); + // Used, the form closes and the image is listed under Your images, chosen. + expect(form()).toBeNull(); + const own = picker().querySelector('[data-collection="yours"]')!; + expect(own.querySelector('[aria-label="sea.jpg"]')!.getAttribute("aria-pressed")).toBe("true"); + expect(own.querySelector(".backdrop-own-bar")!.textContent).toContain("Your images use"); + // The background in use cannot be removed; another of your images can. + const remove = own.querySelector('[aria-label="Remove sea.jpg"]')!; + expect(remove.disabled).toBe(true); + await act(async () => linkTile.click()); + await settle(); + const again = form()!.querySelector('input[type="url"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call( + again, + "https://example.com/hill.png", + ); + again.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await submit(); + const sea = picker().querySelector('[aria-label="Remove sea.jpg"]')!; + expect(sea.disabled).toBe(false); + await act(async () => sea.click()); + await settle(); + expect(runs(page, "cmux.settings.backdrop.image.remove").at(-1)).toMatchObject({ id: "file:/backgrounds/sea.jpg" }); + expect(picker().querySelector('[aria-label="sea.jpg"]')).toBeNull(); + // Show in Finder reveals the folder. + await act(async () => picker().querySelector(".backdrop-own-bar button")!.click()); + expect(runs(page, "cmux.settings.backdrop.image.reveal").length).toBe(1); + page.unmount(); +}); diff --git a/webviews/src/pages/settings/mockProvider.ts b/webviews/src/pages/settings/mockProvider.ts index 73ec1d8b196c..1234eab7f677 100644 --- a/webviews/src/pages/settings/mockProvider.ts +++ b/webviews/src/pages/settings/mockProvider.ts @@ -177,6 +177,27 @@ export class MockSettingsProvider { })[String((params as { section: string }).section)] ?? [], "cmux.settings.backdrop.choose_image": () => ({ id: null }), "cmux.settings.backdrop.wallpaper_settings": () => ({}), + "cmux.settings.backdrop.image.link": (params) => { + const url = String((params as { url: string }).url); + if (!url.startsWith("https://")) + throw new ProtocolError("cmux.settings.backdrop.not_https", "not an https link"); + if (url.endsWith(".txt")) throw new ProtocolError("cmux.settings.backdrop.not_media", "not an image or movie"); + const id = `file:/backgrounds/${url.split("/").pop()}`; + const title = url.split("/").pop() ?? id; + const own = { id, title, attribution: title, collection: "yours", collection_title: "Your images" }; + this.host = { ...this.host, backdrops: [own, ...(this.host.backdrops ?? [])] }; + this.values.set("appearance.background", id); + return { id }; + }, + "cmux.settings.backdrop.image.remove": (params) => { + const id = String((params as { id: string }).id); + this.host = { ...this.host, backdrops: (this.host.backdrops ?? []).filter((row) => row.id !== id) }; + return { bytes: (this.host.backdrops ?? []).filter((row) => row.collection === "yours").length * 1_000_000 }; + }, + "cmux.settings.backdrop.image.reveal": () => ({}), + "cmux.settings.backdrop.image.usage": () => ({ + bytes: (this.host.backdrops ?? []).filter((row) => row.collection === "yours").length * 1_000_000, + }), "cmux.settings.backdrop.workspace": (params) => this.workspaceBackground(String(params.workspace)), "cmux.settings.backdrop.workspace.set": (params) => { const workspace = this.workspaceBackground(String(params.workspace)); @@ -259,6 +280,10 @@ export class MockSettingsProvider { "cmux.settings.folders.add", "cmux.settings.backdrop.choose_image", "cmux.settings.backdrop.wallpaper_settings", + "cmux.settings.backdrop.image.link", + "cmux.settings.backdrop.image.remove", + "cmux.settings.backdrop.image.reveal", + "cmux.settings.backdrop.image.usage", "cmux.settings.backdrop.workspace", "cmux.settings.backdrop.workspace.set", "cmux.settings.backdrop.workspace.preview", diff --git a/webviews/src/pages/settings/ops.ts b/webviews/src/pages/settings/ops.ts index b04b82400cd8..00c620edfbe3 100644 --- a/webviews/src/pages/settings/ops.ts +++ b/webviews/src/pages/settings/ops.ts @@ -323,6 +323,15 @@ export type SettingsOps = { "cmux.settings.folders.add": [{ key: string }, { added: string[] }]; /** The cmux picker for an image; the host copies it to the backgrounds folder and sets it. */ "cmux.settings.backdrop.choose_image": [Record, { id: string | null }]; + /** Native: download an https image or movie and set it as a picked file is; errors carry the + * cmux.settings.backdrop.* refusal codes the picker words. */ + "cmux.settings.backdrop.image.link": [{ url: string }, { id: string }]; + /** Native: delete one of the person's own background images; the bytes the rest take. */ + "cmux.settings.backdrop.image.remove": [{ id: string }, { bytes: number }]; + /** Native: show one of the person's images, or the backgrounds folder, in Finder. */ + "cmux.settings.backdrop.image.reveal": [{ id?: string }, Record]; + /** Native: the bytes the person's own background images take. */ + "cmux.settings.backdrop.image.usage": [Record, { bytes: number }]; /** Native: opens the Wallpaper pane of System Settings, where macOS downloads its wallpapers. */ "cmux.settings.backdrop.wallpaper_settings": [Record, Record]; /** Native: a workspace's own background (cx-t2x.5); `value` null follows appearance.background. */ diff --git a/webviews/src/pages/settings/settings.gallery.ts b/webviews/src/pages/settings/settings.gallery.ts index 3251483dba9d..922c1a17487e 100644 --- a/webviews/src/pages/settings/settings.gallery.ts +++ b/webviews/src/pages/settings/settings.gallery.ts @@ -211,6 +211,19 @@ Object.assign(variants, { steps: [wait('[data-card="backdrop"]')], note: "Background picker with no artwork returned yet: the real empty state keeps the setting usable while the host list loads or is unavailable.", }), + // Add from a Link (backgrounds plan phase 3): the link form open under the picker, a link typed. + "backdrops-link": variant("appearance", { + host: { + ...host, + backdrops: [{ id: "starryNight", title: "Sample night", attribution: "Gallery sample thumbnail" }], + }, + steps: [ + wait('[data-card="backdrop"]'), + click('[data-card="backdrop"] button[aria-expanded] ~ button[aria-expanded]'), + input('[data-card="backdrop-link"] input[type="url"]', "https://example.com/harbor.jpg"), + ], + note: "Add from a Link: an https image or movie downloads into the backgrounds folder and becomes the background.", + }), // Change Background… on a workspace (cx-t2x.5): This Workspace, following All Workspaces. "backdrops-workspace": variant("appearance", { focus: "appearance.background", @@ -778,6 +791,7 @@ export default settingsPageEntry({ "pages/settings/components/AgentHarnesses.tsx#AgentHarnesses", "pages/settings/components/ActionRow.tsx", "pages/settings/components/ArtBrowser.tsx", + "pages/settings/components/BackdropLink.tsx", "pages/settings/components/BackdropScope.tsx", "pages/settings/components/GhosttyDiagnostics.tsx", "pages/settings/components/GroupList.tsx", diff --git a/webviews/src/pages/settings/store.ts b/webviews/src/pages/settings/store.ts index c6914fd5c9aa..ecfb56981fa8 100644 --- a/webviews/src/pages/settings/store.ts +++ b/webviews/src/pages/settings/store.ts @@ -287,6 +287,34 @@ export class SettingsStore { else await Promise.all([this.refresh(), this.refreshHost()]); } + /** Downloads the image or movie at `url` and sets it (backgrounds plan phase 3). Resolves to the + * refusal code the picker words, or null once it is the background. */ + async addBackdropLink(url: string): Promise { + const reply = await this.request("cmux.settings.backdrop.image.link", { url }); + if (!reply.ok) return reply.error.code || "cmux.settings.backdrop.download_failed"; + await Promise.all([this.refresh(), this.refreshHost()]); + return null; + } + + /** Deletes one of the person's own images; resolves to the bytes the rest take, or null. */ + async removeBackdropImage(id: string): Promise { + const reply = await this.request("cmux.settings.backdrop.image.remove", { id }); + if (!reply.ok) return null; + await this.refreshHost(); + return reply.value.bytes; + } + + /** Shows one of the person's images, or with no id the backgrounds folder, in Finder. */ + revealBackdropImage(id?: string): void { + void this.request("cmux.settings.backdrop.image.reveal", id ? { id } : {}); + } + + /** The bytes the person's own images take; null when the host cannot say. */ + async backdropUsage(): Promise { + const reply = await this.request("cmux.settings.backdrop.image.usage", {}); + return reply.ok ? reply.value.bytes : null; + } + /** Opens the Wallpaper pane of System Settings, where macOS downloads a wallpaper the picker * lists as not downloaded. macOS says nothing when it finishes, so the lists are read again * once the person is back in this window. */ diff --git a/webviews/src/pages/settings/styles.css b/webviews/src/pages/settings/styles.css index 3db5db03e6e1..98a59728b673 100644 --- a/webviews/src/pages/settings/styles.css +++ b/webviews/src/pages/settings/styles.css @@ -976,6 +976,41 @@ mark { border-style: dashed; } +/* Your images (backgrounds plan phase 3): what they take, Show in Finder, and a remove + button in each tile's corner, shown on hover or focus. */ +.backdrop-own-bar { + display: flex; + gap: 8px; + align-items: center; + margin-top: 4px; +} + +.backdrop-own { + position: relative; + min-width: 0; +} + +.backdrop-own > .backdrop-tile { + width: 100%; +} + +.backdrop-remove { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 10px; + background: var(--input-bg); + color: var(--text); + opacity: 0; +} + +.backdrop-own:hover .backdrop-remove:not(:disabled), +.backdrop-remove:focus-visible { + opacity: 1; +} + /* Browse More Art (cx-t2x.8). */ .art-browser { margin: 12px 0;