diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf4997b..e3224ef 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -106,6 +106,27 @@ dependencies = [ "security-framework", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log 0.4.33", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -703,6 +724,15 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmov" version = "0.5.4" @@ -1173,13 +1203,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1324,6 +1360,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1363,6 +1405,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1424,6 +1472,7 @@ dependencies = [ "system_shutdown", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", @@ -1444,6 +1493,12 @@ dependencies = [ "zbus-secret-service-keyring-store", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1460,6 +1515,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1701,6 +1762,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1883,6 +1954,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "handlebars" version = "0.29.1" @@ -1892,7 +1974,7 @@ dependencies = [ "lazy_static 0.2.11", "log 0.3.9", "pest", - "quick-error", + "quick-error 1.2.3", "regex 0.2.11", "serde", "serde_json", @@ -1913,13 +1995,22 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1928,7 +2019,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2268,6 +2359,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", ] [[package]] @@ -2809,6 +2901,15 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -2970,6 +3071,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3298,6 +3400,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6dda33d67c26f0aac90d324ab2eb7239c819fc7b2552fe9faa4fe88441edc8" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -3382,7 +3495,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -3551,6 +3664,21 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -4885,6 +5013,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log 0.4.33", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.9" @@ -5239,6 +5382,20 @@ dependencies = [ "lazy_static 1.5.0", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -5620,6 +5777,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5960,6 +6128,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -6071,6 +6309,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6682,6 +6926,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log 0.4.33", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -6762,6 +7024,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.3" @@ -6943,6 +7222,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.13.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eb03ba1..8466eab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" uuid = { version = "1", features = ["v4"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } tauri-plugin-notification = "2.3.3" +tauri-plugin-clipboard-manager = "2.3.2" sysinfo = "0.39.3" hmac = "0.13" sha2 = "0.11" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b7e8a74..8c351e7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "dialog:default", "log:default", "notification:default", - "notification:allow-is-permission-granted" + "notification:allow-is-permission-granted", + "clipboard-manager:allow-read-text" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67de61e..96f0bbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5958,6 +5958,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .on_window_event(|window, event| { if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 08efbff..d6e82bc 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; @@ -18,6 +18,7 @@ import { startActionLabel } from '../utils/downloadActions'; import { isActiveDownloadStatus } from '../utils/downloads'; +import { readClipboardDownloadUrls } from '../utils/clipboard'; interface DownloadTableProps { filter: SidebarFilter; @@ -27,9 +28,19 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170]; const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; export const DownloadTable: React.FC = ({ filter }) => { - const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); + const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore(); const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); + const [isReadingClipboard, setIsReadingClipboard] = useState(false); + const clipboardReadInFlightRef = useRef(false); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [animationParent] = useAutoAnimate(); @@ -355,6 +366,52 @@ export const DownloadTable: React.FC = ({ filter }) => { const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null; + const handleAddDownload = async () => { + if (clipboardReadInFlightRef.current) return; + + clipboardReadInFlightRef.current = true; + setIsReadingClipboard(true); + const store = useDownloadStore.getState(); + const initialModalState = { + isOpen: store.isAddModalOpen, + requestVersion: store.pendingAddRequestVersion, + }; + + try { + const urls = await readClipboardDownloadUrls(); + if (!isMountedRef.current) return; + const currentStore = useDownloadStore.getState(); + + // Do not append a late clipboard result to a newer extension, deep-link, + // paste, or modal request that arrived while the OS clipboard was read. + if ( + currentStore.isAddModalOpen !== initialModalState.isOpen || + currentStore.pendingAddRequestVersion !== initialModalState.requestVersion + ) { + return; + } + + if (urls.length > 0) { + currentStore.openAddModalWithUrls(urls.join('\n')); + } else { + currentStore.toggleAddModal(true); + } + } catch (error) { + console.warn('Could not read clipboard for Add Download:', error); + if (!isMountedRef.current) return; + const currentStore = useDownloadStore.getState(); + if ( + currentStore.isAddModalOpen === initialModalState.isOpen && + currentStore.pendingAddRequestVersion === initialModalState.requestVersion + ) { + currentStore.toggleAddModal(true); + } + } finally { + clipboardReadInFlightRef.current = false; + if (isMountedRef.current) setIsReadingClipboard(false); + } + }; + const getCategoryIcon = (category: string) => { switch(category) { @@ -378,7 +435,13 @@ export const DownloadTable: React.FC = ({ filter }) => {
Firelink
- diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 90a5402..754d169 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -93,6 +93,16 @@ describe('useDownloadStore', () => { }); }); + it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => { + const initialVersion = useDownloadStore.getState().pendingAddRequestVersion; + + useDownloadStore.getState().toggleAddModal(true); + expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 1); + + useDownloadStore.getState().toggleAddModal(false); + expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2); + }); + it('normalizes proxy settings for download dispatch', async () => { expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080'); expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000'); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 41837ca..b19a2fb 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -484,7 +484,7 @@ export const useDownloadStore = create((set, get) => ({ } }), closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }), - toggleAddModal: (isOpen) => set({ + toggleAddModal: (isOpen) => set((state) => ({ isAddModalOpen: isOpen, pendingAddUrls: '', pendingAddReferer: '', @@ -492,8 +492,11 @@ export const useDownloadStore = create((set, get) => ({ pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], - pendingAddRequestContexts: {} - }), + pendingAddRequestContexts: {}, + // Invalidate any in-flight Add-modal handoff even when the modal is + // opened or closed without URLs. + pendingAddRequestVersion: state.pendingAddRequestVersion + 1 + })), openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; diff --git a/src/utils/clipboard.test.ts b/src/utils/clipboard.test.ts new file mode 100644 index 0000000..e05e112 --- /dev/null +++ b/src/utils/clipboard.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readClipboardDownloadUrls } from './clipboard'; +import { readText } from '@tauri-apps/plugin-clipboard-manager'; + +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + readText: vi.fn(), +})); + +describe('clipboard URL extraction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reads only supported, unique download URLs from clipboard text', async () => { + vi.mocked(readText).mockResolvedValue( + 'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin mailto:user@example.com' + ); + + await expect(readClipboardDownloadUrls()).resolves.toEqual([ + 'https://example.com/file.zip', + 'ftp://example.com/file.bin', + ]); + }); + + it('preserves clipboard read failures for the caller to handle', async () => { + const error = new Error('clipboard unavailable'); + vi.mocked(readText).mockRejectedValue(error); + + await expect(readClipboardDownloadUrls()).rejects.toBe(error); + }); +}); diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 0000000..d19a2a9 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,7 @@ +import { readText } from '@tauri-apps/plugin-clipboard-manager'; +import { extractValidDownloadUrls } from './url'; + +export const readClipboardDownloadUrls = async (): Promise => { + const clipboardText = await readText(); + return extractValidDownloadUrls(clipboardText); +};