diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e772e15c..ad0a6153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,7 +168,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev + libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev \ + ffmpeg libopenh264-dev - name: Install Qt uses: jurplel/install-qt-action@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9473a8f3..b5932e26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,75 @@ jobs: name: windows-arm64 path: target/package/*-setup.exe + linux: + name: Linux packages (x86_64) + needs: version + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - name: Install system build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libgl1-mesa-dev libx11-dev libxcb1-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libfontconfig1-dev \ + libfreetype-dev libdbus-1-dev libxtst-dev file desktop-file-utils appstream + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + modules: qtshadertools qtwaylandcompositor + cache: true + aqtsource: git+https://github.com/miurahr/aqtinstall.git + - name: Install appimagetool and the AppImage runtime (pinned, checked) + run: | + curl -fsSL -o /tmp/appimagetool \ + https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage + curl -fsSL -o /tmp/runtime-x86_64 \ + https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64 + sha256sum -c - <<'SUMS' + ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool + 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/runtime-x86_64 + SUMS + chmod +x /tmp/appimagetool + sudo mv /tmp/appimagetool /usr/local/bin/appimagetool + echo "APPIMAGE_RUNTIME=/tmp/runtime-x86_64" >> "$GITHUB_ENV" + - name: Validate desktop entry and AppStream metadata + run: | + desktop-file-validate desktop/packaging/linux/nectarlink.desktop + appstreamcli validate --no-net desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml + - name: Build and package (.deb, AppImage, tarball) + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + cargo build --release -p nectarlink-desktop + ./desktop/packaging/linux/build-deb.sh + ./desktop/packaging/linux/build-appimage.sh + ./desktop/packaging/linux/build-tarball.sh + - name: Check that each package starts + # A package that doesn't start must not reach a release. The .deb goes + # into a clean Ubuntu 24.04, the oldest system the build supports. + run: | + docker run --rm -v "$PWD/dist:/dist:ro" ubuntu:24.04 bash -euc ' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /dist/*.deb >/dev/null + nectarlink-desktop --version' + APPIMAGE_EXTRACT_AND_RUN=1 ./dist/*.AppImage --version + mkdir -p /tmp/tarball && tar -xzf dist/*.tar.gz -C /tmp/tarball + /tmp/tarball/*/nectarlink-desktop --version + - uses: actions/upload-artifact@v7 + with: + name: linux + path: | + dist/*.deb + dist/*.AppImage + dist/*.tar.gz + android: name: Android app (signed) needs: version @@ -147,7 +216,7 @@ jobs: publish: name: Draft the release - needs: [windows, windows-arm64, android] + needs: [windows, windows-arm64, linux, android] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 @@ -169,7 +238,7 @@ jobs: name=$(awk '/^\[workspace.metadata.release\]/{f=1;next} /^\[/{f=0} f && /^name = /{print $3}' source/Cargo.toml | tr -d '"') title="NectarLink ${GITHUB_REF_NAME#v}${name:+ $name}" cd files - sha256sum *.exe *.apk > SHA256SUMS.txt + sha256sum *.exe *.deb *.AppImage *.tar.gz *.apk > SHA256SUMS.txt cat SHA256SUMS.txt # The notes: this version's section of CHANGELOG.md ("## ..."), # with its wrapped lines joined, or GitHub's generated notes when @@ -177,5 +246,5 @@ jobs: awk -v v="${GITHUB_REF_NAME#v}" '$1=="##" && $2==v {f=1; next} f && /^## / {exit} f' \ ../source/CHANGELOG.md | awk -f ../source/.github/scripts/unwrap-notes.awk > notes.md || true if [ -s notes.md ]; then notes=(--notes-file notes.md); else notes=(--generate-notes); fi - gh release create "${{ github.ref_name }}" *.exe *.apk SHA256SUMS.txt \ + gh release create "${{ github.ref_name }}" *.exe *.deb *.AppImage *.tar.gz *.apk SHA256SUMS.txt \ --repo "${{ github.repository }}" --draft "${notes[@]}" --title "$title" diff --git a/Cargo.lock b/Cargo.lock index b9a21159..0d4b44c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,40 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -1065,6 +1099,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.4.1" @@ -1076,6 +1116,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1092,6 +1153,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1432,6 +1513,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.26.3" @@ -2206,6 +2293,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2407,6 +2503,7 @@ dependencies = [ "tracing-subscriber", "windows", "windows-core", + "zbus", ] [[package]] @@ -2859,6 +2956,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3588,6 +3695,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3898,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4032,6 +4150,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4282,6 +4401,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -4474,6 +4604,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5088,6 +5229,71 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -5187,3 +5393,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 524ac0dd..67ada86d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ url = "2.5" windows = "0.62" # The same version windows itself depends on, never newer on its own. windows-core = "0.62" +zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] } [workspace.lints.rust] unsafe_code = "deny" diff --git a/core/nectarlink-adb/src/lib.rs b/core/nectarlink-adb/src/lib.rs index 48c4a3f9..1ef820cd 100644 --- a/core/nectarlink-adb/src/lib.rs +++ b/core/nectarlink-adb/src/lib.rs @@ -151,16 +151,41 @@ pub async fn forward_tcp( if !is_serial_allowed(serial) { return Err(AdbError::BlockedSerial(serial.to_owned())); } - let port = if local_port == 0 { - let listener = TcpListener::bind("127.0.0.1:0").await?; - listener.local_addr()?.port() - } else { - local_port - }; - let mut stream = TcpStream::connect(server).await?; - let cmd = format!("host-serial:{serial}:forward:tcp:{port};tcp:{remote_port}"); - send_command(&mut stream, &cmd).await?; - Ok(port) + let mut port = if local_port == 0 { free_local_port().await? } else { local_port }; + let mut attempts = 0; + loop { + let mut stream = TcpStream::connect(server).await?; + let cmd = format!("host-serial:{serial}:forward:tcp:{port};tcp:{remote_port}"); + match send_command(&mut stream, &cmd).await { + Ok(()) => return Ok(port), + // A port that was free a moment ago can still be taken by the time + // adb binds it (on WSL the Windows side can hold a port the Linux + // side just closed). Only then is another port worth trying. + Err(AdbError::Remote(reason)) + if local_port == 0 && attempts < BIND_RETRIES && is_bind_failure(&reason) => + { + attempts += 1; + port = free_local_port().await?; + } + Err(e) => return Err(e), + } + } +} + +/// How many other ports [`forward_tcp`] tries when adb can't bind the one it picked. +const BIND_RETRIES: u32 = 4; + +/// A local TCP port that is free right now. +async fn free_local_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + Ok(listener.local_addr()?.port()) +} + +/// Whether an adb `FAIL` reason means the local port couldn't be bound, +/// for example "cannot bind listener: Address already in use". +fn is_bind_failure(reason: &str) -> bool { + let reason = reason.to_ascii_lowercase(); + reason.contains("cannot bind") || reason.contains("already in use") } /// Removes a TCP port forward (`adb -s forward --remove tcp:`). @@ -256,6 +281,67 @@ emulator-5554 device product:sdk_gphone64_x86_64 model:Pixel_9 device:e assert!(!devices[2].is_online()); } + #[test] + fn only_bind_failures_count_as_bind_failures() { + assert!(is_bind_failure( + "cannot bind listener: cannot bind to 127.0.0.1:5000: Address already in use (98)" + )); + assert!(is_bind_failure("Address already in use")); + assert!(!is_bind_failure("device offline")); + assert!(!is_bind_failure("device unauthorized.")); + assert!(!is_bind_failure("more than one device/emulator")); + } + + /// A mock adb server that answers each `forward` with the next of + /// `replies` (`None` = OKAY, `Some(reason)` = FAIL) and records the commands. + async fn forward_server(replies: Vec>) -> (SocketAddr, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let commands = Arc::new(Mutex::new(Vec::new())); + let seen = commands.clone(); + tokio::spawn(async move { + let mut replies = replies.into_iter(); + while let Ok((mut socket, _)) = listener.accept().await { + let Ok(cmd) = read_payload(&mut socket).await else { continue }; + seen.lock().unwrap().push(cmd); + match replies.next().flatten() { + None => { + let _ = socket.write_all(b"OKAY").await; + } + Some(reason) => { + let frame = format!("FAIL{:04x}{reason}", reason.len()); + let _ = socket.write_all(frame.as_bytes()).await; + } + } + let _ = socket.flush().await; + } + }); + (addr, commands) + } + + #[tokio::test] + async fn forward_tries_another_port_only_when_adb_cannot_bind() { + let (addr, commands) = + forward_server(vec![Some("cannot bind listener: Address already in use"), None]).await; + let port = forward_tcp(addr, "emulator-5554", 0, 53319).await.unwrap(); + let commands = commands.lock().unwrap().clone(); + assert_eq!(commands.len(), 2, "one retry after the bind failure"); + assert!(commands[1].contains(&format!("forward:tcp:{port};tcp:53319"))); + + let (addr, commands) = forward_server(vec![Some("device offline")]).await; + let err = forward_tcp(addr, "emulator-5554", 0, 53319).await.unwrap_err(); + assert!(matches!(err, AdbError::Remote(ref r) if r == "device offline")); + assert_eq!(commands.lock().unwrap().len(), 1, "other errors are not retried"); + + let (addr, commands) = forward_server(vec![Some("cannot bind listener"); 10]).await; + assert!(forward_tcp(addr, "emulator-5554", 0, 53319).await.is_err()); + assert_eq!(commands.lock().unwrap().len(), 1 + BIND_RETRIES as usize, "retries are bounded"); + + let (addr, commands) = forward_server(vec![Some("cannot bind listener")]).await; + assert!(forward_tcp(addr, "emulator-5554", 5000, 53319).await.is_err()); + assert_eq!(commands.lock().unwrap().len(), 1, "a port the caller chose is not swapped"); + } + #[tokio::test] async fn mock_adb_server_forward_killforward_and_track_devices() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/core/nectarlink-cli/src/main.rs b/core/nectarlink-cli/src/main.rs index 71693db6..eaa2000d 100644 --- a/core/nectarlink-cli/src/main.rs +++ b/core/nectarlink-cli/src/main.rs @@ -3696,8 +3696,16 @@ async fn run(cli: &Cli, node: &Node) -> Result<()> { .map(str::to_owned), ); if node_power(cli) == PowerLevel::Elevated { + SCREEN_APPS.store(true, std::sync::atomic::Ordering::Relaxed); offers.extend( - [nectarlink_core::TOGGLES_WIFI, nectarlink_core::TOGGLES_BLUETOOTH].map(str::to_owned), + [ + nectarlink_core::TOGGLES_WIFI, + nectarlink_core::TOGGLES_BLUETOOTH, + nectarlink_core::MIRROR_CAPTURE, + nectarlink_core::MIRROR_INPUT, + nectarlink_core::MIRROR_VIRTUAL_DISPLAY, + ] + .map(str::to_owned), ); } node.update_power(node_power(cli), offers).await; @@ -4234,6 +4242,16 @@ async fn pair(node: &Node) -> Result<()> { async fn nearby(node: &Node, peer: DeviceId) -> Result<()> { let mut events = node.events(); + if !node.discovered_devices().iter().any(|d| d.id == peer) { + let _ = tokio::time::timeout(Duration::from_secs(5), async { + while let Ok(ev) = events.recv().await { + if matches!(ev, NodeEvent::Discovered(ref d) if d.id == peer) { + break; + } + } + }) + .await; + } let mut task = { let node = node.clone(); tokio::spawn(async move { node.pairing_start_nearby(peer).await }) diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml index 0eaaa449..50937db0 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -114,9 +114,12 @@ windows = { workspace = true, features = [ "Win32_Media_MediaFoundation", ] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +zbus.workspace = true + [build-dependencies] cxx-qt-build = { workspace = true, features = ["link_qt_object_files"] } embed-resource.workspace = true diff --git a/desktop/app/build.rs b/desktop/app/build.rs index 0803f6b8..b5757ca6 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -365,8 +365,11 @@ fn main() { let module = QmlModule::new("app.nectarlink") .qml_files(QML.iter().map(|f| QmlFile::from(*f))) .qml_files(QML_SINGLETONS.iter().map(|f| QmlFile::from(*f).singleton(true))); - CxxQtBuilder::new_qml_module(module) - .qt_module("Quick") + let mut builder = CxxQtBuilder::new_qml_module(module).qt_module("Quick"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + builder = builder.qt_module("Widgets"); + } + builder .include_dir("cpp") .cpp_files([ "cpp/app_helpers.cpp", @@ -407,6 +410,7 @@ fn main() { println!("cargo:rustc-link-lib=user32"); } else if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { println!("cargo:rustc-link-lib=Qt6Quick"); + println!("cargo:rustc-link-lib=Qt6Widgets"); println!( "cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN:$ORIGIN/lib:$ORIGIN/../lib:$ORIGIN/../lib/nectarlink/lib" ); diff --git a/desktop/app/cpp/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp index 619415c1..e215eafe 100644 --- a/desktop/app/cpp/app_helpers.cpp +++ b/desktop/app/cpp/app_helpers.cpp @@ -33,6 +33,22 @@ #include #endif +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + namespace { QIcon &appIcon() { @@ -538,3 +554,717 @@ uint64_t mac_free_space(rust::Str path, bool &ok) ok = true; return static_cast(available); } + +#ifdef __linux__ +namespace { +std::atomic &qtEventLoopRunning() +{ + static std::atomic running { false }; + return running; +} + +struct LinuxClipboardWatch { + std::mutex mutex; + std::condition_variable cv; + std::atomic seq { 1 }; +}; + +LinuxClipboardWatch &linuxClipboardWatch() +{ + static LinuxClipboardWatch *watch = new LinuxClipboardWatch(); + return *watch; +} + +struct LinuxTrayState { + QPointer icon; + QPointer menu; + QPointer takePhotoAction; + QPointer scanDocumentAction; + QPointer findPhoneAction; + QPointer openLinkAction; + bool phoneActionsEnabled = false; + std::mutex mutex; + std::condition_variable cv; + std::deque actions; +}; + +LinuxTrayState &linuxTray() +{ + static LinuxTrayState *state = new LinuxTrayState(); + return *state; +} + +void pushTrayAction(const char *action) +{ + auto &st = linuxTray(); + { + std::lock_guard lock(st.mutex); + st.actions.emplace_back(action); + } + st.cv.notify_all(); +} + +template +void runOnQtMainThread(F &&fn) +{ + QCoreApplication *app = QCoreApplication::instance(); + if (!app) + return; + if (QThread::currentThread() == app->thread()) { + fn(); + } else { + QMetaObject::invokeMethod(app, std::forward(fn), Qt::QueuedConnection); + } +} + +template +bool runOnQtMainThreadSync(F &&fn, uint32_t timeout_ms = 500) +{ + QCoreApplication *app = QCoreApplication::instance(); + if (!app) + return false; + if (QThread::currentThread() == app->thread()) { + fn(); + return true; + } + if (!qtEventLoopRunning().load(std::memory_order_acquire)) + return false; + struct SyncState { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + }; + auto state = std::make_shared(); + auto task = std::make_shared>(std::forward(fn)); + QMetaObject::invokeMethod( + app, + [state, task]() { + (*task)(); + { + std::lock_guard lock(state->mutex); + state->done = true; + } + state->cv.notify_all(); + }, + Qt::QueuedConnection); + std::unique_lock lock(state->mutex); + return state->cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&state]() { + return state->done; + }); +} +} // namespace +#endif + +std::unique_ptr create_qt_app() +{ + static int s_argc = 1; + static char s_arg0[] = "nectarlink"; + static char *s_argv[] = { s_arg0, nullptr }; +#ifdef __linux__ + auto app = std::unique_ptr(new QApplication(s_argc, s_argv)); + QObject::connect(app.get(), &QCoreApplication::aboutToQuit, app.get(), []() { + qtEventLoopRunning().store(false, std::memory_order_release); + }); + QMetaObject::invokeMethod( + app.get(), + []() { + qtEventLoopRunning().store(true, std::memory_order_release); + }, + Qt::QueuedConnection); + if (QClipboard *cb = QGuiApplication::clipboard()) { + QObject::connect(cb, &QClipboard::dataChanged, cb, []() { + linux_clipboard_notify_external(); + }); + } + return app; +#else + return std::make_unique(s_argc, s_argv); +#endif +} + +void linux_tray_setup( + rust::Str icon_path, + rust::Str tooltip, + rust::Str open_label, + rust::Str command_palette_label, + rust::Str shelf_label, + rust::Str take_photo_label, + rust::Str scan_document_label, + rust::Str find_phone_label, + rust::Str open_link_label, + rust::Str quit_label) +{ +#ifdef __linux__ + const QString iconPath = QString::fromUtf8(icon_path.data(), static_cast(icon_path.size())); + const QString tooltipStr = QString::fromUtf8(tooltip.data(), static_cast(tooltip.size())); + const QString openStr = QString::fromUtf8(open_label.data(), static_cast(open_label.size())); + const QString paletteStr = QString::fromUtf8(command_palette_label.data(), static_cast(command_palette_label.size())); + const QString shelfStr = QString::fromUtf8(shelf_label.data(), static_cast(shelf_label.size())); + const QString photoStr = QString::fromUtf8(take_photo_label.data(), static_cast(take_photo_label.size())); + const QString scanStr = QString::fromUtf8(scan_document_label.data(), static_cast(scan_document_label.size())); + const QString findPhoneStr = QString::fromUtf8(find_phone_label.data(), static_cast(find_phone_label.size())); + const QString openLinkStr = QString::fromUtf8(open_link_label.data(), static_cast(open_link_label.size())); + const QString quitStr = QString::fromUtf8(quit_label.data(), static_cast(quit_label.size())); + + runOnQtMainThread([=]() { + if (!qobject_cast(QCoreApplication::instance())) + return; + auto &st = linuxTray(); + if (st.icon) { + st.icon->hide(); + delete st.icon.data(); + } + if (st.menu) { + delete st.menu.data(); + } + { + std::lock_guard lock(st.mutex); + st.actions.clear(); + } + + // Menu order matches win/tray.rs: + // Take Photo, Scan Document, Separator, Open, Command Palette, Shelf, Find Phone, Open Link, Separator, Quit. + const bool enabled = st.phoneActionsEnabled; + auto *menu = new QMenu(); + QAction *takePhoto = menu->addAction(photoStr); + takePhoto->setEnabled(enabled); + QObject::connect(takePhoto, &QAction::triggered, menu, []() { pushTrayAction("TAKE_PHOTO"); }); + + QAction *scanDoc = menu->addAction(scanStr); + scanDoc->setEnabled(enabled); + QObject::connect(scanDoc, &QAction::triggered, menu, []() { pushTrayAction("SCAN_DOCUMENT"); }); + + menu->addSeparator(); + QObject::connect(menu->addAction(openStr), &QAction::triggered, menu, []() { pushTrayAction("OPEN"); }); + QObject::connect(menu->addAction(paletteStr), &QAction::triggered, menu, []() { pushTrayAction("COMMAND_PALETTE"); }); + QObject::connect(menu->addAction(shelfStr), &QAction::triggered, menu, []() { pushTrayAction("SHELF"); }); + + QAction *findPhone = menu->addAction(findPhoneStr); + findPhone->setEnabled(enabled); + QObject::connect(findPhone, &QAction::triggered, menu, []() { pushTrayAction("FIND_PHONE"); }); + + QAction *openLink = menu->addAction(openLinkStr); + openLink->setEnabled(enabled); + QObject::connect(openLink, &QAction::triggered, menu, []() { pushTrayAction("OPEN_LINK"); }); + + menu->addSeparator(); + QObject::connect(menu->addAction(quitStr), &QAction::triggered, menu, []() { pushTrayAction("QUIT"); }); + + auto *tray = new QSystemTrayIcon(QCoreApplication::instance()); + QIcon icon = appIcon(); + if (icon.isNull() && !iconPath.isEmpty()) + icon = QIcon(iconPath); + tray->setIcon(icon); + tray->setToolTip(tooltipStr); + tray->setContextMenu(menu); + QObject::connect(tray, &QSystemTrayIcon::activated, tray, [](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) + pushTrayAction("OPEN"); + }); + tray->show(); + + st.icon = tray; + st.menu = menu; + st.takePhotoAction = takePhoto; + st.scanDocumentAction = scanDoc; + st.findPhoneAction = findPhone; + st.openLinkAction = openLink; + }); +#else + (void)icon_path; + (void)tooltip; + (void)open_label; + (void)command_palette_label; + (void)shelf_label; + (void)take_photo_label; + (void)scan_document_label; + (void)find_phone_label; + (void)open_link_label; + (void)quit_label; +#endif +} + +void linux_tray_set_tooltip(rust::Str tooltip) +{ +#ifdef __linux__ + const QString tooltipStr = QString::fromUtf8(tooltip.data(), static_cast(tooltip.size())); + runOnQtMainThread([tooltipStr]() { + if (auto *icon = linuxTray().icon.data()) + icon->setToolTip(tooltipStr); + }); +#else + (void)tooltip; +#endif +} + +void linux_tray_set_find_phone_enabled(bool enabled) +{ +#ifdef __linux__ + runOnQtMainThread([enabled]() { + auto &st = linuxTray(); + st.phoneActionsEnabled = enabled; + if (auto *act = st.takePhotoAction.data()) + act->setEnabled(enabled); + if (auto *act = st.scanDocumentAction.data()) + act->setEnabled(enabled); + if (auto *act = st.findPhoneAction.data()) + act->setEnabled(enabled); + if (auto *act = st.openLinkAction.data()) + act->setEnabled(enabled); + }); +#else + (void)enabled; +#endif +} + +void linux_tray_teardown() +{ +#ifdef __linux__ + auto &st = linuxTray(); + st.cv.notify_all(); + runOnQtMainThread([]() { + auto &s = linuxTray(); + if (s.icon) { + s.icon->hide(); + delete s.icon.data(); + } + if (s.menu) { + delete s.menu.data(); + } + s.takePhotoAction = nullptr; + s.scanDocumentAction = nullptr; + s.findPhoneAction = nullptr; + s.openLinkAction = nullptr; + }); +#endif +} + +rust::String linux_tray_wait_action(uint32_t timeout_ms) +{ +#ifdef __linux__ + auto &st = linuxTray(); + std::unique_lock lock(st.mutex); + if (st.actions.empty() && timeout_ms > 0) { + st.cv.wait_for(lock, std::chrono::milliseconds(timeout_ms)); + } + if (!st.actions.empty()) { + std::string next = std::move(st.actions.front()); + st.actions.pop_front(); + return rust::String(next); + } +#else + (void)timeout_ms; +#endif + return rust::String(); +} + +bool linux_tray_is_available() +{ +#ifdef __linux__ + bool available = false; + if (runOnQtMainThreadSync([&available]() { + available = QSystemTrayIcon::isSystemTrayAvailable(); + })) { + return available; + } +#endif + return false; +} + +bool linux_work_area(int32_t &x, int32_t &y, int32_t &w, int32_t &h) +{ +#ifdef __linux__ + bool ok = false; + int32_t rx = 0, ry = 0, rw = 0, rh = 0; + if (runOnQtMainThreadSync([&]() { + if (QScreen *screen = QGuiApplication::primaryScreen()) { + const QRect avail = screen->availableGeometry(); + const qreal dpr = screen->devicePixelRatio() > 0.0 ? screen->devicePixelRatio() : 1.0; + if (avail.width() > 0 && avail.height() > 0) { + rx = static_cast(avail.x() * dpr); + ry = static_cast(avail.y() * dpr); + rw = static_cast(avail.width() * dpr); + rh = static_cast(avail.height() * dpr); + ok = true; + } + } + }) + && ok) { + x = rx; + y = ry; + w = rw; + h = rh; + return true; + } +#else + (void)x; + (void)y; + (void)w; + (void)h; +#endif + return false; +} + +bool linux_screen_geometry(int32_t &x, int32_t &y, int32_t &w, int32_t &h) +{ +#ifdef __linux__ + bool ok = false; + int32_t rx = 0, ry = 0, rw = 0, rh = 0; + if (runOnQtMainThreadSync([&]() { + if (QScreen *screen = QGuiApplication::primaryScreen()) { + const QRect geom = screen->geometry(); + const qreal dpr = screen->devicePixelRatio() > 0.0 ? screen->devicePixelRatio() : 1.0; + if (geom.width() > 0 && geom.height() > 0) { + rx = static_cast(geom.x() * dpr); + ry = static_cast(geom.y() * dpr); + rw = static_cast(geom.width() * dpr); + rh = static_cast(geom.height() * dpr); + ok = true; + } + } + }) + && ok) { + x = rx; + y = ry; + w = rw; + h = rh; + return true; + } +#else + (void)x; + (void)y; + (void)w; + (void)h; +#endif + return false; +} + +void linux_set_activation_token(rust::Str token) +{ +#ifdef __linux__ + if (token.empty()) + return; + const QByteArray tok(token.data(), static_cast(token.size())); + runOnQtMainThread([tok]() { + qputenv("XDG_ACTIVATION_TOKEN", tok); + }); +#else + (void)token; +#endif +} + +uint64_t linux_clipboard_sequence() +{ +#ifdef __linux__ + return linuxClipboardWatch().seq.load(std::memory_order_acquire); +#else + return 0; +#endif +} + +uint64_t linux_clipboard_wait_change(uint64_t last_seq, uint32_t timeout_ms) +{ +#ifdef __linux__ + auto &w = linuxClipboardWatch(); + uint64_t cur = w.seq.load(std::memory_order_acquire); + if (cur != last_seq || timeout_ms == 0) + return cur; + std::unique_lock lock(w.mutex); + w.cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&w, last_seq]() { + return w.seq.load(std::memory_order_acquire) != last_seq; + }); + return w.seq.load(std::memory_order_acquire); +#else + (void)last_seq; + (void)timeout_ms; + return 0; +#endif +} + +void linux_clipboard_notify_external() +{ +#ifdef __linux__ + auto &w = linuxClipboardWatch(); + { + std::lock_guard lock(w.mutex); + w.seq.fetch_add(1, std::memory_order_release); + } + w.cv.notify_all(); +#endif +} + +bool linux_clipboard_read( + bool check_privacy, + bool &is_private, + bool &has_files, + rust::String &text, + rust::String &html, + rust::String &rtf, + rust::Vec &png_bytes) +{ + is_private = false; + has_files = false; + text = rust::String(); + html = rust::String(); + rtf = rust::String(); + png_bytes.clear(); +#ifdef __linux__ + bool ran = false; + bool priv = false; + bool files = false; + std::string outText; + std::string outHtml; + std::string outRtf; + QByteArray outPng; + + if (!runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + ran = true; + const QMimeData *mime = cb->mimeData(QClipboard::Clipboard); + if (!mime) + return; + + const QStringList formats = mime->formats(); + if (check_privacy) { + for (const QString &fmt : formats) { + if (fmt.compare(QStringLiteral("x-gnome-privacy-hint"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("ExcludeClipboardContentFromMonitorProcessing"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("Clipboard Viewer Ignore"), Qt::CaseInsensitive) == 0) { + priv = true; + return; + } + if (fmt.compare(QStringLiteral("x-kde-passwordManagerHint"), Qt::CaseInsensitive) == 0) { + const QByteArray val = mime->data(fmt).trimmed(); + if (val.isEmpty() || QString::fromUtf8(val).trimmed().compare(QStringLiteral("secret"), Qt::CaseInsensitive) == 0) { + priv = true; + return; + } + } + } + } + + if (mime->hasFormat(QStringLiteral("image/png"))) { + outPng = mime->data(QStringLiteral("image/png")); + if (!outPng.isEmpty()) + return; + } + if (mime->hasImage()) { + const QImage img = qvariant_cast(mime->imageData()); + if (!img.isNull()) { + QBuffer buf(&outPng); + if (buf.open(QIODevice::WriteOnly) && img.save(&buf, "PNG")) { + return; + } + outPng.clear(); + } + } + + for (const QString &fmt : formats) { + if (fmt.compare(QStringLiteral("x-special/gnome-copied-files"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("x-special/mate-copied-files"), Qt::CaseInsensitive) == 0) { + files = true; + return; + } + } + if (mime->hasUrls()) { + for (const QUrl &u : mime->urls()) { + if (u.isLocalFile()) { + files = true; + return; + } + } + } + + if (mime->hasText()) { + outText = mime->text().toStdString(); + } + if (mime->hasHtml()) { + outHtml = mime->html().toStdString(); + } + if (mime->hasFormat(QStringLiteral("text/rtf"))) { + const QByteArray r = mime->data(QStringLiteral("text/rtf")); + outRtf.assign(r.constData(), static_cast(r.size())); + } else if (mime->hasFormat(QStringLiteral("application/rtf"))) { + const QByteArray r = mime->data(QStringLiteral("application/rtf")); + outRtf.assign(r.constData(), static_cast(r.size())); + } + })) { + return false; + } + if (!ran) + return false; + + is_private = priv; + has_files = files; + text = rust::String(outText); + html = rust::String(outHtml); + rtf = rust::String(outRtf); + if (!outPng.isEmpty()) { + png_bytes.reserve(static_cast(outPng.size())); + const auto *raw = reinterpret_cast(outPng.constData()); + for (qsizetype i = 0; i < outPng.size(); ++i) + png_bytes.push_back(raw[i]); + } + return true; +#else + (void)check_privacy; + return false; +#endif +} + +rust::Vec linux_clipboard_read_files() +{ + rust::Vec result; +#ifdef __linux__ + std::vector paths; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + const QMimeData *mime = cb->mimeData(QClipboard::Clipboard); + if (!mime) + return; + if (mime->hasUrls()) { + for (const QUrl &u : mime->urls()) { + if (u.isLocalFile()) { + const QString local = u.toLocalFile(); + if (!local.isEmpty() && QFileInfo::exists(local)) + paths.push_back(local.toStdString()); + } + } + } + if (paths.empty()) { + for (const char *fmt : { "x-special/gnome-copied-files", "x-special/mate-copied-files", "text/uri-list" }) { + const QString qfmt = QString::fromLatin1(fmt); + if (mime->hasFormat(qfmt)) { + const QString raw = QString::fromUtf8(mime->data(qfmt)); + for (const QString &line : raw.split(QLatin1Char('\n'))) { + const QString trimmed = line.trimmed(); + if (trimmed.startsWith(QStringLiteral("file://"))) { + const QUrl u(trimmed); + if (u.isLocalFile()) { + const QString local = u.toLocalFile(); + if (!local.isEmpty() && QFileInfo::exists(local)) + paths.push_back(local.toStdString()); + } + } + } + if (!paths.empty()) + break; + } + } + } + })) { + for (const std::string &p : paths) + result.push_back(rust::String(p)); + } +#endif + return result; +} + +bool linux_clipboard_write_text(rust::Str text, rust::Str html, rust::Str rtf, bool is_private) +{ +#ifdef __linux__ + const QString qtext = QString::fromUtf8(text.data(), static_cast(text.size())); + const QString qhtml = QString::fromUtf8(html.data(), static_cast(html.size())); + const QByteArray qrtf(rtf.data(), static_cast(rtf.size())); + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setText(qtext); + if (is_private) { + mime->setData(QStringLiteral("x-kde-passwordManagerHint"), QByteArrayLiteral("secret")); + mime->setData(QStringLiteral("x-gnome-privacy-hint"), QByteArrayLiteral("1")); + mime->setData(QStringLiteral("ExcludeClipboardContentFromMonitorProcessing"), QByteArrayLiteral("1")); + mime->setData(QStringLiteral("Clipboard Viewer Ignore"), QByteArrayLiteral("1")); + } else { + if (!qhtml.isEmpty()) + mime->setHtml(qhtml); + if (!qrtf.isEmpty()) { + mime->setData(QStringLiteral("text/rtf"), qrtf); + mime->setData(QStringLiteral("application/rtf"), qrtf); + } + } + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)text; + (void)html; + (void)rtf; + (void)is_private; +#endif + return false; +} + +bool linux_clipboard_write_image_png(rust::Slice png) +{ +#ifdef __linux__ + if (png.empty()) + return false; + const QByteArray bytes(reinterpret_cast(png.data()), static_cast(png.size())); + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setData(QStringLiteral("image/png"), bytes); + QImage img; + if (img.loadFromData(bytes, "PNG") && !img.isNull()) + mime->setImageData(img); + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)png; +#endif + return false; +} + +bool linux_clipboard_write_files(rust::Slice paths) +{ +#ifdef __linux__ + if (paths.empty()) + return false; + QList urls; + QByteArray gnomePayload("copy"); + for (const rust::String &p : paths) { + const QString qp = QString::fromUtf8(p.data(), static_cast(p.size())); + if (!qp.isEmpty()) { + const QUrl u = QUrl::fromLocalFile(qp); + urls.append(u); + gnomePayload.append('\n'); + gnomePayload.append(u.toEncoded()); + } + } + if (urls.isEmpty()) + return false; + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setUrls(urls); + mime->setData(QStringLiteral("x-special/gnome-copied-files"), gnomePayload); + mime->setData(QStringLiteral("x-special/mate-copied-files"), gnomePayload); + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)paths; +#endif + return false; +} diff --git a/desktop/app/cpp/app_helpers.h b/desktop/app/cpp/app_helpers.h index 27da9f67..9a04219a 100644 --- a/desktop/app/cpp/app_helpers.h +++ b/desktop/app/cpp/app_helpers.h @@ -4,6 +4,13 @@ #include #include +#include + +class QGuiApplication; + +// Creates the Qt application instance (`QApplication` on Linux so +// `QSystemTrayIcon` and `QMenu` work; `QGuiApplication` elsewhere). +std::unique_ptr create_qt_app(); // Qt setup that must happen before QGuiApplication is created. void prepare_qt(); @@ -62,3 +69,41 @@ bool mac_clipboard_write_text(rust::Str text, bool is_private); int64_t mac_local_offset_ms(int64_t at_ms); uint64_t mac_free_space(rust::Str path, bool &ok); + +// Linux QSystemTrayIcon + QMenu, screen geometry, activation token, and QClipboard. +void linux_tray_setup( + rust::Str icon_path, + rust::Str tooltip, + rust::Str open_label, + rust::Str command_palette_label, + rust::Str shelf_label, + rust::Str take_photo_label, + rust::Str scan_document_label, + rust::Str find_phone_label, + rust::Str open_link_label, + rust::Str quit_label); +void linux_tray_set_tooltip(rust::Str tooltip); +void linux_tray_set_find_phone_enabled(bool enabled); +void linux_tray_teardown(); +rust::String linux_tray_wait_action(uint32_t timeout_ms); +bool linux_tray_is_available(); + +bool linux_work_area(int32_t &x, int32_t &y, int32_t &w, int32_t &h); +bool linux_screen_geometry(int32_t &x, int32_t &y, int32_t &w, int32_t &h); +void linux_set_activation_token(rust::Str token); + +uint64_t linux_clipboard_sequence(); +uint64_t linux_clipboard_wait_change(uint64_t last_seq, uint32_t timeout_ms); +void linux_clipboard_notify_external(); +bool linux_clipboard_read( + bool check_privacy, + bool &is_private, + bool &has_files, + rust::String &text, + rust::String &html, + rust::String &rtf, + rust::Vec &png_bytes); +rust::Vec linux_clipboard_read_files(); +bool linux_clipboard_write_text(rust::Str text, rust::Str html, rust::Str rtf, bool is_private); +bool linux_clipboard_write_image_png(rust::Slice png); +bool linux_clipboard_write_files(rust::Slice paths); diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index 1731550b..79526eea 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -2,6 +2,7 @@ #include "native_window.h" #include "app_helpers.h" +#include #include #include #include @@ -15,9 +16,17 @@ #include #include #include +#elif defined(__linux__) +#include +#include +#include +#include +#include +#endif namespace { +#ifdef _WIN32 // Values from the Windows 11 SDK, defined here so older SDK headers work. constexpr DWORD kUseImmersiveDarkMode = 20; // DWMWA_USE_IMMERSIVE_DARK_MODE constexpr DWORD kCaptionColor = 35; // DWMWA_CAPTION_COLOR @@ -49,26 +58,312 @@ void trimD3DResources(QQuickWindow *window) dxgi3->Release(); } } - -} // namespace #else -namespace { -void trimD3DResources(QQuickWindow * /*window*/) {} -} // namespace +void trimD3DResources(QQuickWindow *) +{ +} #endif +#ifdef __linux__ +int ignoreX11Error(void *, void *) +{ + return 0; +} + +void activateX11Window(WId winId) +{ + if (!winId || qEnvironmentVariableIsEmpty("DISPLAY")) + return; + // On Wayland, QWindow::winId() is not an X11 Window ID even when XWayland sets DISPLAY. + if (QGuiApplication::platformName() != QLatin1String("xcb")) + return; + + void *lib = dlopen("libX11.so.6", RTLD_LAZY | RTLD_LOCAL); + if (!lib) + return; + + using XErrorHandlerFn = int (*)(void *, void *); + using XSetErrorHandlerFn = XErrorHandlerFn (*)(XErrorHandlerFn); + using XOpenDisplayFn = void *(*)(const char *); + using XCloseDisplayFn = int (*)(void *); + using XDefaultRootWindowFn = unsigned long (*)(void *); + using XInternAtomFn = unsigned long (*)(void *, const char *, int); + using XSendEventFn = int (*)(void *, unsigned long, int, long, void *); + using XMapRaisedFn = int (*)(void *, unsigned long); + using XSetInputFocusFn = int (*)(void *, unsigned long, int, unsigned long); + using XSyncFn = int (*)(void *, int); + using XFlushFn = int (*)(void *); + + auto xSetErrorHandler = reinterpret_cast(dlsym(lib, "XSetErrorHandler")); + auto xOpenDisplay = reinterpret_cast(dlsym(lib, "XOpenDisplay")); + auto xCloseDisplay = reinterpret_cast(dlsym(lib, "XCloseDisplay")); + auto xDefaultRootWindow = reinterpret_cast(dlsym(lib, "XDefaultRootWindow")); + auto xInternAtom = reinterpret_cast(dlsym(lib, "XInternAtom")); + auto xSendEvent = reinterpret_cast(dlsym(lib, "XSendEvent")); + auto xMapRaised = reinterpret_cast(dlsym(lib, "XMapRaised")); + auto xSetInputFocus = reinterpret_cast(dlsym(lib, "XSetInputFocus")); + auto xSync = reinterpret_cast(dlsym(lib, "XSync")); + auto xFlush = reinterpret_cast(dlsym(lib, "XFlush")); + + if (xOpenDisplay && xCloseDisplay && xDefaultRootWindow && xInternAtom && xSendEvent && xFlush) { + if (void *dpy = xOpenDisplay(nullptr)) { + XErrorHandlerFn prevHandler = xSetErrorHandler ? xSetErrorHandler(ignoreX11Error) : nullptr; + const unsigned long root = xDefaultRootWindow(dpy); + const unsigned long window = static_cast(winId); + const unsigned long netActiveWindow = xInternAtom(dpy, "_NET_ACTIVE_WINDOW", 0); + + if (xMapRaised) + xMapRaised(dpy, window); + + struct XClientMessageEventCompat { + int type; + unsigned long serial; + int send_event; + void *display; + unsigned long window; + unsigned long message_type; + int format; + long l[5]; + long pad[12]; + } ev {}; + ev.type = 33; // ClientMessage + ev.send_event = 1; + ev.display = dpy; + ev.window = window; + ev.message_type = netActiveWindow; + ev.format = 32; + ev.l[0] = 2; // Source indication: 2 = pager / direct user action (bypasses WM focus-stealing prevention) + ev.l[1] = 0; // CurrentTime + ev.l[2] = 0; + + constexpr long kSubstructureRedirectMask = (1L << 20); + constexpr long kSubstructureNotifyMask = (1L << 19); + xSendEvent(dpy, root, 0, kSubstructureRedirectMask | kSubstructureNotifyMask, &ev); + + if (xSetInputFocus) + xSetInputFocus(dpy, window, 1 /* RevertToPointerRoot */, 0 /* CurrentTime */); + + if (xSync) + xSync(dpy, 0); + else + xFlush(dpy); + if (xSetErrorHandler) + xSetErrorHandler(prevHandler); + xCloseDisplay(dpy); + } + } + dlclose(lib); +} + +class LinuxWindowEventFilter : public QObject +{ +public: + explicit LinuxWindowEventFilter(NativeWindow *window) + : QObject(window) + , window_(window) + { + } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (watched != window_) + return QObject::eventFilter(watched, event); + + switch (event->type()) { + case QEvent::MouseMove: { + auto *me = static_cast(event); + if (me->buttons() == Qt::NoButton) { + pendingMove_ = false; + manualMove_ = false; + const Qt::Edges edges = hitResizeEdges(me->position()); + updateCursorForEdges(edges); + } else if (me->buttons() & Qt::LeftButton) { + if (pendingMove_) { + if ((me->position() - pressPos_).manhattanLength() >= 4) { + pendingMove_ = false; + if (!window_->startSystemMove()) { + manualMove_ = true; + } + return true; + } + } else if (manualMove_) { + const QPointF delta = me->globalPosition() - globalPressPos_; + window_->setPosition(winStartPos_ + delta.toPoint()); + return true; + } + } + break; + } + case QEvent::MouseButtonPress: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + manualMove_ = false; + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + if (edges != Qt::Edges()) { + window_->startSystemResize(edges); + return true; + } + const qreal titleBarHeight = window_->captionHeight(); + if (pos.y() < titleBarHeight + && !window_->isCaptionHoleAt(pos) + && !hitInteractiveControl(window_->contentItem(), pos)) { + pendingMove_ = true; + pressPos_ = pos; + globalPressPos_ = me->globalPosition(); + winStartPos_ = window_->position(); + } + } + break; + } + case QEvent::MouseButtonRelease: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + if (manualMove_) { + manualMove_ = false; + return true; + } + } + break; + } + case QEvent::MouseButtonDblClick: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + manualMove_ = false; + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + const qreal titleBarHeight = window_->captionHeight(); + if (edges == Qt::Edges() && pos.y() < titleBarHeight + && !window_->isCaptionHoleAt(pos) + && !hitInteractiveControl(window_->contentItem(), pos)) { + window_->toggleMaximized(); + return true; + } + } + break; + } + case QEvent::Leave: + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + break; + default: + break; + } + return QObject::eventFilter(watched, event); + } + +private: + Qt::Edges hitResizeEdges(const QPointF &pos) const + { + if (!window_ || window_->visibility() == QWindow::Maximized + || window_->minimumSize() == window_->maximumSize()) { + return {}; + } + if (pos.y() < window_->captionHeight() + && (window_->isCaptionHoleAt(pos) || hitInteractiveControl(window_->contentItem(), pos))) { + return {}; + } + const qreal w = window_->width(); + const qreal h = window_->height(); + const qreal b = resizeBorderWidth_; + Qt::Edges edges; + if (pos.x() < b) + edges |= Qt::LeftEdge; + else if (pos.x() >= w - b) + edges |= Qt::RightEdge; + if (pos.y() < b) + edges |= Qt::TopEdge; + else if (pos.y() >= h - b) + edges |= Qt::BottomEdge; + return edges; + } + + void updateCursorForEdges(Qt::Edges edges) + { + if (edges == Qt::Edges()) { + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + return; + } + Qt::CursorShape shape = Qt::ArrowCursor; + if ((edges == (Qt::TopEdge | Qt::LeftEdge)) || (edges == (Qt::BottomEdge | Qt::RightEdge))) + shape = Qt::SizeFDiagCursor; + else if ((edges == (Qt::TopEdge | Qt::RightEdge)) || (edges == (Qt::BottomEdge | Qt::LeftEdge))) + shape = Qt::SizeBDiagCursor; + else if (edges & (Qt::LeftEdge | Qt::RightEdge)) + shape = Qt::SizeHorCursor; + else if (edges & (Qt::TopEdge | Qt::BottomEdge)) + shape = Qt::SizeVerCursor; + window_->setCursor(QCursor(shape)); + borderCursorActive_ = true; + } + + bool hitInteractiveControl(QQuickItem *item, const QPointF &scenePos) const + { + if (!item || !item->isVisible() || !item->isEnabled() || item->opacity() <= 0.0) + return false; + const QPointF localPos = item->mapFromScene(scenePos); + if (!item->contains(localPos)) + return false; + if (item == window_->maximizeButton() || item->property("_nl_caption_hole").toBool()) + return true; + const auto children = item->childItems(); + for (int i = children.size() - 1; i >= 0; --i) { + if (hitInteractiveControl(children[i], scenePos)) + return true; + } + if (item != window_->contentItem() + && item->acceptedMouseButtons() != Qt::NoButton) { + return true; + } + return false; + } + + NativeWindow *window_ = nullptr; + int resizeBorderWidth_ = 6; + bool borderCursorActive_ = false; + bool pendingMove_ = false; + bool manualMove_ = false; + QPointF pressPos_; + QPointF globalPressPos_; + QPoint winStartPos_; +}; +#endif + +} // namespace + NativeWindow::NativeWindow(QWindow *parent) : QQuickWindow(parent) { + // Qt removes the system title bar and lets content fill the window. #ifdef _WIN32 - // Qt removes the system title bar and lets content fill the window on Windows. setFlags(flags() | Qt::ExpandedClientAreaHint); -#endif setColor(Qt::transparent); +#elif defined(__linux__) + setFlags(flags() | Qt::FramelessWindowHint); + setColor(QColor(0x14, 0x13, 0x11)); +#else + setColor(Qt::transparent); +#endif // Drop the scene graph and D3D/RHI resources when the window is hidden or // closed to the tray instead of keeping GPU buffers in private commit. setPersistentSceneGraph(false); setPersistentGraphics(false); + connect(this, &QWindow::visibilityChanged, this, [this](QWindow::Visibility) { + emit windowVisibilityChanged(); + }); +#ifdef __linux__ + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); +#endif } void NativeWindow::setBackdrop(bool on) @@ -125,12 +420,16 @@ void NativeWindow::setMaximizeState(bool hovered, bool pressed) void NativeWindow::addCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", true); if (item && std::none_of(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h == item; })) m_holes.emplace_back(item); } void NativeWindow::removeCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", false); m_holes.erase(std::remove_if(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h.isNull() || h == item; }), m_holes.end()); @@ -146,8 +445,12 @@ void NativeWindow::toggleMaximized() void NativeWindow::bringToFront() { - if (visibility() == QWindow::Minimized || !isVisible()) + if (visibility() == QWindow::Minimized) { + setWindowStates((windowStates() & ~Qt::WindowMinimized) | Qt::WindowActive); + show(); + } else if (!isVisible()) { showNormal(); + } raise(); requestActivate(); #ifdef _WIN32 @@ -169,14 +472,17 @@ void NativeWindow::bringToFront() SetForegroundWindow(hwnd); if (attached) AttachThreadInput(thisThread, foregroundThread, FALSE); +#elif defined(__linux__) + if (handle()) + activateX11Window(winId()); #endif } void NativeWindow::flash() { -#ifdef _WIN32 if (!handle()) return; +#ifdef _WIN32 FLASHWINFO info { sizeof(FLASHWINFO), hwndOf(this), FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0 }; FlashWindowEx(&info); #else @@ -197,6 +503,16 @@ void NativeWindow::releaseAndTeardown() destroy(); } +bool NativeWindow::grabToFile(const QString &path) +{ + if (path.isEmpty()) + return false; + const QImage image = grabWindow(); + if (image.isNull()) + return false; + return image.save(path); +} + bool NativeWindow::event(QEvent *event) { if (QQmlEngine *engine = qmlEngine(this)) @@ -237,6 +553,14 @@ void NativeWindow::applyFrame() DwmSetWindowAttribute(hwnd, kSystemBackdropType, &backdrop, sizeof(backdrop)); const COLORREF none = kColorNone; DwmSetWindowAttribute(hwnd, kCaptionColor, &none, sizeof(none)); +#elif defined(__linux__) + setColor(m_darkFrame ? QColor(0x14, 0x13, 0x11) : QColor(0xF8, 0xF6, 0xF2)); + if (!property("_nl_linux_filter").toBool()) { + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); + } +#else + setColor(m_darkFrame ? QColor(0x14, 0x13, 0x11) : QColor(0xF8, 0xF6, 0xF2)); #endif } @@ -247,6 +571,17 @@ bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoin return item->mapRectToScene(QRectF(0, 0, item->width(), item->height())).contains(scenePoint); } +bool NativeWindow::isCaptionHoleAt(const QPointF &scenePoint) const +{ + if (itemContains(m_maximizeButton, scenePoint)) + return true; + for (const auto &hole : m_holes) { + if (itemContains(hole, scenePoint)) + return true; + } + return false; +} + long NativeWindow::hitTest(int screenX, int screenY) const { #ifdef _WIN32 @@ -348,10 +683,6 @@ bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintp default: break; } -#else - Q_UNUSED(eventType); - Q_UNUSED(message); - Q_UNUSED(result); #endif return QQuickWindow::nativeEvent(eventType, message, result); } diff --git a/desktop/app/cpp/native_window.h b/desktop/app/cpp/native_window.h index ad06b6bb..b97f8341 100644 --- a/desktop/app/cpp/native_window.h +++ b/desktop/app/cpp/native_window.h @@ -63,6 +63,10 @@ class NativeWindow : public QQuickWindow // Releases scene-graph/D3D resources, destroys the native HWND, and // schedules deletion when closing to the tray. Q_INVOKABLE void releaseAndTeardown(); + // Captures the rendered window contents to an image file on disk. + Q_INVOKABLE bool grabToFile(const QString &path); + + bool isCaptionHoleAt(const QPointF &scenePoint) const; signals: @@ -72,6 +76,7 @@ class NativeWindow : public QQuickWindow void captionHeightChanged(); void maximizeButtonChanged(); void maximizeStateChanged(); + void windowVisibilityChanged(); void closeRequested(); protected: diff --git a/desktop/app/i18n/nectarlink_de.ts b/desktop/app/i18n/nectarlink_de.ts index 5c6ae837..7221f1a6 100644 --- a/desktop/app/i18n/nectarlink_de.ts +++ b/desktop/app/i18n/nectarlink_de.ts @@ -4,157 +4,157 @@ App - + Already on clipboard. Bereits in der Zwischenablage. - + Command palette Befehlspalette - + Copied to clipboard. In die Zwischenablage kopiert. - + Could not copy to the clipboard. Konnte nicht in die Zwischenablage kopiert werden. - + Downloading the update · %1 of %2 Update wird heruntergeladen · %1 von %2 - + Downloading the update… Update wird heruntergeladen… - + Find my phone Mein Gerät finden - - My phone's colors + + My phone's colors Farben meines Geräts - - My phone's colors (%1) + + My phone's colors (%1) Farben meines Geräts (%1) - + NectarLink · %1 NectarLink · %1 - + NectarLink · %1 devices connected NectarLink · %1 Geräte verbunden - + NectarLink · 1 device connected NectarLink · 1 Gerät verbunden - + NectarLink · no devices paired NectarLink · keine Geräte gekoppelt - + NectarLink · not connected NectarLink · nicht verbunden - + No phone is paired yet. Noch kein Gerät gekoppelt. - + Open NectarLink NectarLink öffnen - + Open copied link on phone Kopierten Link auf dem Gerät öffnen - + Quit NectarLink NectarLink beenden - + Scan document with phone Dokument mit dem Gerät scannen - + Send cancelled Senden abgebrochen - + Sending to %1… Wird an %1 gesendet… - + Sent text to %1. Text an %1 gesendet. - - Some history couldn't be cleared. Try again. + + Some history couldn't be cleared. Try again. Ein Teil des Verlaufs konnte nicht gelöscht werden. Versuche es erneut. - - Some messages couldn't be cleared. Try again. + + Some messages couldn't be cleared. Try again. Einige Nachrichten konnten nicht gelöscht werden. Versuche es erneut. - + Take photo with phone Foto mit dem Gerät aufnehmen - + That file is no longer in its saved location. Diese Datei befindet sich nicht mehr am gespeicherten Ort. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - - The received file history couldn't be cleared. Try again. + + The received file history couldn't be cleared. Try again. Der Verlauf empfangener Dateien konnte nicht gelöscht werden. Versuche es erneut. - + Toggle Shelf Shelf ein-/ausblenden - + Wi-Fi %1/4 WLAN %1/4 - + Your phone Dein Gerät @@ -162,72 +162,77 @@ AppsPage - + Phone apps Geräte-Apps - - Open %1's apps here + + Open %1's apps here Apps von %1 hier öffnen - + Pair your phone to open its apps in windows on this PC. Kopple dein Gerät, um seine Apps in Fenstern auf diesem PC zu öffnen. - + + Opening apps in windows isn't supported on this phone. + Apps in Fenstern zu öffnen wird auf diesem Gerät nicht unterstützt. + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. Richte Kabelloses Debugging in NectarLink auf dem Gerät ein, um seine Apps hier zu öffnen. - + Search apps Apps durchsuchen - - Search %1's apps + + Search %1's apps Apps von %1 durchsuchen - + Refresh Aktualisieren - + No apps match. Keine passenden Apps gefunden. - + No apps to open. Keine Apps zum Öffnen vorhanden. - - Getting the phone's apps… + + Getting the phone's apps… Geräte-Apps werden geladen… - + Try again Erneut versuchen - + Recent Zuletzt verwendet - + All apps Alle Apps - + %1, open %1, geöffnet @@ -235,43 +240,43 @@ AppsSheet - + Open an app from %1 App von %1 öffnen - + Refresh Aktualisieren - - + + Search apps Apps durchsuchen - + No apps match. Keine passenden Apps gefunden. - + No apps to open. Keine Apps zum Öffnen vorhanden. - - Getting the phone's apps… + + Getting the phone's apps… Geräte-Apps werden geladen… - + Recent Zuletzt verwendet - + All apps Alle Apps @@ -279,7 +284,7 @@ Automations - + Triggered by %1 Ausgelöst durch %1 @@ -287,17 +292,17 @@ Battery - + %1 is fully charged %1 ist vollständig geladen - + You can unplug it. Du kannst das Ladegerät trennen. - + Your phone Dein Gerät @@ -305,17 +310,17 @@ BtHfp - + Bluetooth connected (%1). Bluetooth verbunden (%1). - + Bluetooth paired with %1 (not currently connected). Per Bluetooth mit %1 gekoppelt (derzeit nicht verbunden). - + No paired Bluetooth phone endpoint found in Windows. Kein gekoppeltes Bluetooth-Gerät in Windows gefunden. @@ -323,72 +328,72 @@ CallCard - + On a call Im Gespräch - + On hold Gehalten - + Muted Stummgeschaltet - + Mute Stummschalten - + Speaker Lautsprecher - + Resume Fortsetzen - + Hold Halten - + Keypad Tastenfeld - + Mute, speaker and keypad need Elevated on the phone (Android 12 or later). Stummschalten, Lautsprecher und Tastenfeld erfordern den erweiterten Modus auf dem Gerät (ab Android 12). - + Connect PC audio PC-Audio verbinden - + Bluetooth settings Bluetooth-Einstellungen - + Call volume down Anruflautstärke verringern - + End call Anruf beenden - + Call volume up Anruflautstärke erhöhen @@ -396,27 +401,27 @@ Calls - + Calling %1 %1 wird angerufen - + Missed call from %1 Verpasster Anruf von %1 - + On a call with %1 Im Gespräch mit %1 - + That call is over. Dieser Anruf ist beendet. - + Unknown caller Unbekannter Anrufer @@ -424,283 +429,283 @@ CallsPage - + Today Heute - - + + Yesterday Gestern - + %1s %1s - + Call from your PC Vom PC aus anrufen - - - - %1 isn't connected + + + + %1 isn't connected %1 ist nicht verbunden - + Pair your phone to view calls and contacts. Kopple dein Gerät, um Anrufe und Kontakte anzuzeigen. - + Connect %1 to view calls and contacts. Verbinde %1, um Anrufe und Kontakte anzuzeigen. - + Recent Verlauf - - + + Contacts Kontakte - + Refresh Aktualisieren - - + + Search calls Anrufe durchsuchen - - + + Search contacts Kontakte durchsuchen - + Clear search Suche löschen - + Calls are off for %1 Anrufe sind für %1 deaktiviert - + Allow call log on %1 Anrufliste auf %1 erlauben - - Couldn't load recent calls + + Couldn't load recent calls Anrufliste konnte nicht geladen werden - + Enable Calls in Settings. Aktiviere Anrufe in den Einstellungen. - + Grant call log permission on %1. Erteile die Berechtigung für die Anrufliste auf %1. - + Connect %1 to view recent calls. Verbinde %1, um die letzten Anrufe anzuzeigen. - + Unable to load call log. Anrufliste kann nicht geladen werden. - - + + Try again Erneut versuchen - + No calls match. Keine passenden Anrufe gefunden. - + No recent calls. Keine aktuellen Anrufe. - + Contacts are off for %1 Kontakte sind für %1 deaktiviert - + Allow contacts on %1 Kontakte auf %1 erlauben - - Couldn't load contacts + + Couldn't load contacts Kontakte konnten nicht geladen werden - + Enable Contacts in Settings. Aktiviere Kontakte in den Einstellungen. - + Grant contacts permission on %1. Erteile die Kontakte-Berechtigung auf %1. - + Connect %1 to view contacts. Verbinde %1, um Kontakte anzuzeigen. - + Unable to load contacts. Kontakte können nicht geladen werden. - + No contacts match. Keine passenden Kontakte gefunden. - + No contacts on this phone. Keine Kontakte auf diesem Gerät. - + Close contact Kontakt schließen - + Copy number Nummer kopieren - - - + + + Text %1 SMS an %1 - - - + + + Call %1 %1 anrufen - + Phone number to call Anzurufende Telefonnummer - + Enter a number Nummer eingeben - + Backspace Rücktaste - + Text SMS - + Calling… Wird angerufen… - + Call Anrufen - + Clear number Nummer löschen - + PC call audio connected PC-Anrufaudio verbunden - + PC call audio ready PC-Anrufaudio bereit - + PC call audio (Bluetooth) PC-Anrufaudio (Bluetooth) - + Connect PC audio PC-Audio verbinden - + Open Bluetooth settings Bluetooth-Einstellungen öffnen - + Missed Verpasst - + Outgoing Ausgehend - + Declined Abgelehnt - + Incoming Eingehend - + Favorites Favoriten - + +%1 more +%1 weitere @@ -708,22 +713,22 @@ CaptionButtons - + Minimize Minimieren - + Restore Wiederherstellen - + Maximize Maximieren - + Close Schließen @@ -731,570 +736,570 @@ Clipboard - + Copied from %1 Von %1 kopiert - + Copied from %1. Von %1 kopiert. - + Copied to clipboard In die Zwischenablage kopiert - + Sending copied files to %1… Kopierte Dateien werden an %1 gesendet… - + Sent to %1. An %1 gesendet. - + That clip is no longer in clipboard history. Dieser Eintrag ist nicht mehr im Zwischenablageverlauf. - - That image can't be read. + + That image can't be read. Dieses Bild kann nicht gelesen werden. - + That image is too large to send. Dieses Bild ist zu groß zum Senden. - - That's too much text to send at once. + + That's too much text to send at once. Das ist zu viel Text auf einmal. - + The clipboard is turned off for %1. Die Zwischenablage ist für %1 deaktiviert. - - There's no text, image, or file on the clipboard. + + There's no text, image, or file on the clipboard. Es befinden sich weder Text noch Bilder oder Dateien in der Zwischenablage. - + Update NectarLink on %1 to send it images. Aktualisiere NectarLink auf %1, um Bilder dorthin zu senden. - - What you copied is marked private by its app, so it wasn't sent. + + What you copied is marked private by its app, so it wasn't sent. Der kopierte Inhalt wurde von der App als vertraulich markiert und daher nicht gesendet. CommandPalette - + Type a command, contact, or app… Befehl, Kontakt oder App eingeben… - + Command palette search Suche in der Befehlspalette - + Clear search Suche löschen - + Close command palette Befehlspalette schließen - + Clear Löschen - + Esc Esc - + No matching commands Keine passenden Befehle - + ↑↓ to navigate · Enter to run · Esc to close ↑↓ zum Navigieren · Eingabe zum Ausführen · Esc zum Schließen - + %1 · %2 ms %1 · %2 ms - + Browse all 28 features, live capability status, and where to find them Alle 28 Funktionen, den Live-Status und ihre Fundorte durchsuchen - + Browse phone storage in File Explorer Gerätespeicher im Datei-Explorer durchsuchen - + Call %1 %1 anrufen - + Calling %1 on %2… %1 wird auf %2 angerufen… - + Capture a photo on your phone and paste it into the active window Foto mit dem Gerät aufnehmen und in das aktive Fenster einfügen - + Check Wake-on-LAN status in Settings Wake-on-LAN-Status in den Einstellungen prüfen - + Connection Doctor Verbindungs-Doktor - + Continue copied video on phone Kopiertes Video auf dem Gerät fortsetzen - + Copied image Kopiertes Bild - + Copy: %1 Kopieren: %1 - + Diagnose and fix firewall, discovery, or network issues Firewall-, Erkennungs- oder Netzwerkprobleme diagnostizieren und beheben - + Features guide Funktionsübersicht - + Find %1 %1 finden - + Find my phone Mein Gerät finden - + From %1 Von %1 - + Hand off document to phone Dokument an das Gerät übergeben - + Handoff · Doc Übergabe · Dok. - + Handoff · Map Übergabe · Karte - + Handoff · Video Übergabe · Video - + Hide Shelf Shelf ausblenden - + Make %1 ring at full volume %1 bei voller Lautstärke klingeln lassen - + Make phone ring at full volume Gerät bei voller Lautstärke klingeln lassen - + Mirror %1 %1 spiegeln - + Mirror Apps Apps spiegeln - + Mirror phone screen Gerätebildschirm spiegeln - - Mute %1's ringer + + Mute %1's ringer Klingelton von %1 stummschalten - + No physical network adapter found for Wake-on-LAN. Kein physischer Netzwerkadapter für Wake-on-LAN gefunden. - + Open %1 %1 öffnen - + Open %1 in a window from %2 %1 in einem Fenster von %2 öffnen - - Open %1's Cloud Files folder in Explorer + + Open %1's Cloud Files folder in Explorer Cloud-Dateien-Ordner von %1 im Explorer öffnen - + Open Shelf Shelf öffnen - + Open conversation on %1 Unterhaltung auf %1 öffnen - + Open copied link on phone Kopierten Link auf dem Gerät öffnen - + Open copied location in Maps on phone Kopierten Ort in Maps auf dem Gerät öffnen - + Open last photo Letztes Foto öffnen - + Open last photo from %1 Letztes Foto von %1 öffnen - + Open newest photo or screenshot Neuestes Foto oder neuesten Screenshot öffnen - + Open the newest photo or screenshot from %1 Neuestes Foto oder neuesten Screenshot von %1 öffnen - + Open voice recordings folder for %1 Ordner für Sprachaufnahmen von %1 öffnen - + Opened Recordings folder. Start a voice note in NectarLink on %1 to send it here. Ordner „Aufnahmen“ geöffnet. Starte eine Sprachnotiz in NectarLink auf %1, um sie hierher zu senden. - + Pair a new phone Neues Gerät koppeln - + Place a phone call or open in dialer Telefonanruf starten oder im Wählfeld öffnen - + Place call on %1 Anruf auf %1 starten - + Quick Toggles Schnelleinstellungen - + Re-open the step-by-step setup walkthrough Schritt-für-Schritt-Einrichtungsassistenten erneut öffnen - + Ready on %1 Bereit auf %1 - + Recent Activity Letzte Aktivitäten - + Reconnect paired phones and refresh notifications and media Gekoppelte Geräte neu verbinden sowie Benachrichtigungen und Medien aktualisieren - + Record voice Sprache aufnehmen - + Ringer: Silent Klingelton: Lautlos - + Ringer: Sound Klingelton: Ton - + Ringer: Vibrate Klingelton: Vibrieren - + Ringing %1… %1 klingelt… - + Scan a document on your phone and paste it into the active window Dokument mit deinem Gerät scannen und in das aktive Fenster einfügen - + Scan document with phone Dokument mit dem Gerät scannen - + Send a document to %1 and open it automatically upon arrival Dokument an %1 senden und nach dem Empfang automatisch öffnen - + Send clipboard Zwischenablage senden - + Send clipboard to %1 Zwischenablage an %1 senden - + Send copied text or image Kopierten Text oder kopiertes Bild senden - + Send copied text or image to %1 Kopierten Text oder kopiertes Bild an %1 senden - + Send live keystrokes and text to %1 without screen mirroring Tastenanschläge und Text ohne Bildschirmspiegelung live an %1 senden - - Set %1's ringer mode to normal sound + + Set %1's ringer mode to normal sound Klingeltonmodus von %1 auf Ton stellen - - Set %1's ringer mode to vibrate + + Set %1's ringer mode to vibrate Klingeltonmodus von %1 auf Vibration stellen - + Setup walkthrough Einrichtungsassistent - + Show QR code and pairing code to link another phone QR-Code und Kopplungscode anzeigen, um ein weiteres Gerät zu verbinden - + Slide-out edge panel with latest photo, screenshot, clipboard, and recent files Seitenleiste am Bildschirmrand mit neuestem Foto, Screenshot, Zwischenablage und aktuellen Dateien - + Start a new message conversation Neue Nachrichtenunterhaltung beginnen - + Starting phone webcam… Geräte-Webcam wird gestartet… - + Stop phone webcam Geräte-Webcam beenden - + Stop typing on phone Tippen auf dem Gerät beenden - + Stopped phone webcam. Geräte-Webcam beendet. - - Stream %1's camera into the NectarLink virtual webcam + + Stream %1's camera into the NectarLink virtual webcam Kamera von %1 an die virtuelle NectarLink-Webcam übertragen - - Stream %1's screen in a desktop window + + Stream %1's screen in a desktop window Bildschirm von %1 in einem Desktop-Fenster übertragen - + Switch Bluetooth on %1 Bluetooth auf %1 umschalten - + Switch Do Not Disturb on %1 „Nicht stören“ auf %1 umschalten - + Switch Wi-Fi on %1 WLAN auf %1 umschalten - + Switch to %1 Zu %1 wechseln - + Switch torch / flashlight on %1 Taschenlampe auf %1 umschalten - + Sync now Jetzt synchronisieren - + Syncing devices… Geräte werden synchronisiert… - + Take photo with phone Foto mit dem Gerät aufnehmen - + Text %1 SMS an %1 - + That file is no longer in its saved location. Diese Datei befindet sich nicht mehr am gespeicherten Ort. - + Toggle Bluetooth Bluetooth umschalten - + Toggle Do Not Disturb „Nicht stören“ umschalten - + Toggle Flashlight Taschenlampe umschalten - + Toggle Wi-Fi WLAN umschalten - + Type on phone with PC keyboard Mit der PC-Tastatur auf dem Gerät tippen - + Use phone as webcam Gerät als Webcam nutzen - + Wake-on-LAN is ready on %1 (phones can wake this PC). Wake-on-LAN ist auf %1 bereit (Geräte können diesen PC aufwecken). - + Wake-on-LAN on %1 is %2. Check Settings. Wake-on-LAN auf %1 ist %2. Prüfe die Einstellungen. @@ -1302,67 +1307,67 @@ ContinuityCamera - + Allow Photos access for %1 in Settings first. Erlaube zuerst den Fotozugriff für %1 in den Einstellungen. - + Camera capture cancelled on %1. Kameraaufnahme auf %1 abgebrochen. - + Camera is busy on %1. Die Kamera auf %1 wird bereits verwendet. - + Camera permission was denied on %1. Die Kameraberechtigung wurde auf %1 verweigert. - + Couldn’t copy image to clipboard: %1 Bild konnte nicht in die Zwischenablage kopiert werden: %1 - + Couldn’t open camera on %1: %2 Kamera auf %1 konnte nicht geöffnet werden: %2 - + Image is on your clipboard. Press Ctrl+V in your target app. Das Bild ist in deiner Zwischenablage. Drücke Strg+V in der Ziel-App. - + Opening camera on %1… Kamera auf %1 wird geöffnet… - + Opening document scanner on %1… Dokumentenscanner auf %1 wird geöffnet… - + Pair your phone first to take a photo or scan a document. Kopple zuerst dein Gerät, um ein Foto aufzunehmen oder ein Dokument zu scannen. - + Pasted into %1 In %1 eingefügt - + Scan a document on %1… Dokument auf %1 scannen… - + Take a photo on %1… Foto auf %1 aufnehmen… @@ -1370,7 +1375,7 @@ CoreHost - + Dropped %1 queued item(s) for %2 (device unpaired). %1 wartende(s) Element(e) für %2 verworfen (Gerät entkoppelt). @@ -1378,77 +1383,82 @@ Deck - + Alt + Tab Alt + Tab - + Application path must end with .exe or .lnk. Der Anwendungspfad muss auf .exe oder .lnk enden. - + Choose a key for the shortcut. Wähle eine Taste für das Tastenkürzel. - + Choose an application (.exe or .lnk) on this PC. Wähle eine Anwendung (.exe oder .lnk) auf diesem PC. - + Enter a command to run on this PC. Gib einen Befehl ein, der auf diesem PC ausgeführt werden soll. - + + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + Gib den Pfad einer Anwendung ein (/usr/bin/..., .desktop oder .AppImage). + + + Enter an http:// or https:// web address. Gib eine Webadresse mit http:// oder https:// ein. - + Enter the text snippet to type. Gib den einzufügenden Textbaustein ein. - + Invalid action configuration. Ungültige Aktionskonfiguration. - + Invalid tile properties. Ungültige Kacheleigenschaften. - + Media key Medientaste - + Page not found. Seite nicht gefunden. - + This page already has the maximum number of tiles (24). Diese Seite hat bereits die maximale Anzahl an Kacheln (24). - + Tile not found. Kachel nicht gefunden. - + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). Verwende eine einzelne Taste (a-z, 0-9, f1-f12, enter, tab, space, escape, Pfeiltasten usw.). - + Web address must start with http:// or https://. Die Webadresse muss mit http:// oder https:// beginnen. @@ -1456,383 +1466,398 @@ DeckPage - - - + + + Main Hauptseite - - - + + + Media Medien - + Play / pause Wiedergabe / Pause - + Previous track Vorheriger Titel - + Next track Nächster Titel - - - - + + + + Audio Audio - + Volume down Leiser - + Volume up Lauter - + Mute speaker Lautsprecher stummschalten - + Mute microphone Mikrofon stummschalten - - - - + + + + System System - + Show desktop (Win+D) Desktop anzeigen (Win+D) - + + Show desktop (Super+D) + Desktop anzeigen (Super+D) + + + Switch window (Alt+Tab) Fenster wechseln (Alt+Tab) - + Screenshot (Win+Shift+S) Screenshot (Win+Umschalt+S) - + + Screenshot + Screenshot + + + Lock PC PC sperren - - - + + + Custom Benutzerdefiniert - + Keyboard shortcut Tastenkürzel - + Open website Website öffnen - + Type text snippet Textbaustein tippen - - + + Apps Apps - + Launch application (.desktop / binary) Anwendung starten (.desktop / Programmdatei) - + Launch application (.exe / .lnk) Anwendung starten (.exe / .lnk) - + Run shell command Shell-Befehl ausführen - + Choose an application or shortcut Anwendung oder Verknüpfung auswählen - + Applications and launchers (*.desktop *.AppImage *) Anwendungen und Starter (*.desktop *.AppImage *) - + All files (*) Alle Dateien (*) - + Applications and shortcuts (*.exe *.lnk) Anwendungen und Verknüpfungen (*.exe *.lnk) - + Launch app App starten - + Control Deck Steuerungs-Deck - + Drag to reorder · click to edit. Ziehen zum Neuordnen · Klicken zum Bearbeiten. - + Reset defaults Auf Standard zurücksetzen - - - - + + + + Add tile Kachel hinzufügen - + Speaker muted (%1%) Lautsprecher stumm (%1%) - + Speaker %1% Lautsprecher %1% - + Mic muted Mikro stumm - + Mic live Mikro aktiv - + No mic Kein Mikro - + Media playing Medienwiedergabe aktiv - + Media paused Medien pausiert - - + + New page Neue Seite - + Page %1 Seite %1 - - + + Rename page Seite umbenennen - + Delete page Seite löschen - + Test %1 %1 testen - + Edit tile Kachel bearbeiten - - Phones see only each tile's label and icon. Paths and commands stay on this PC. + + Phones see only each tile's label and icon. Paths and commands stay on this PC. Geräte sehen nur die Beschriftung und das Symbol jeder Kachel. Pfade und Befehle bleiben auf diesem PC. - + Action Aktion - + Ctrl Strg - + Alt Alt - + Shift Umschalt - + Win Win - + + Super + Super + + + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): Taste (z. B. c, v, z, f5, tab, enter, space, escape, up, down): - + Website address (https://…): Website-Adresse (https://…): - + Text snippet to type: Einzutippender Textbaustein: - + Application or launcher path (/usr/bin/…, .desktop, or .AppImage): Anwendungs- oder Starterpfad (/usr/bin/…, .desktop oder .AppImage): - + Application or shortcut path (.exe or .lnk): Anwendungs- oder Verknüpfungspfad (.exe oder .lnk): - + Shell command to run on this PC: Auf diesem PC auszuführender Shell-Befehl: - + Action parameter Aktionsparameter - + Browse… Durchsuchen… - - Requires "Allow running Deck commands" to be turned on for the phone in Settings → Devices. + + Requires "Allow running Deck commands" to be turned on for the phone in Settings → Devices. Erfordert, dass unter Einstellungen → Geräte für das Gerät „Deck-Befehle ausführen“ aktiviert ist. - + Label Beschriftung - + Tile label Kachelbeschriftung - + Icon Symbol - + Delete Löschen - + Test Testen - - - + + + Cancel Abbrechen - - + + Save Speichern - + Page name Seitenname - + Create Erstellen - + Reset Deck to defaults? Deck auf Standard zurücksetzen? - + This replaces your custom pages and tiles with the default 11-tile Deck. Dadurch werden deine benutzerdefinierten Seiten und Kacheln durch das Standard-Deck mit 11 Kacheln ersetzt. - + Reset Zurücksetzen @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. %1 Öffne NectarLink auf dem Gerät und halte beide Geräte im selben Netzwerk. Wenn das Gerät nach der Ausführung im Hintergrund fragt, erlaube sie. - + %1 days ago vor %1 Tagen - + %1 hours ago vor %1 Stunden - + %1 is connected %1 ist verbunden - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) %1 ist in diesem WLAN, aber der Router blockiert direkte Verbindungen (AP-Isolation) - + %1 is set as a public network %1 ist als öffentliches Netzwerk eingestellt - - %1 isn't connected + + %1 isn't connected %1 ist nicht verbunden - + %1 minutes ago vor %1 Minuten - + %1 · %2 ms %1 · %2 ms - + A VPN is on Ein VPN ist aktiv - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + Eine Firewall-Regel blockiert eingehende NectarLink-Verbindungen. Erlaube sie, damit sich dein Gerät verbinden kann. + + + + Calls and alerts can't play sound yet + Anrufe und Hinweise können noch keinen Ton abspielen + + + Connect it to the same Wi-Fi or wired network as your phone. Verbinde ihn mit demselben WLAN oder LAN wie dein Gerät. - + Connected to %1 Verbunden mit %1 - + Connected to a network Mit einem Netzwerk verbunden - + Connecting to %1… %1 wird verbunden… - - It hasn't connected yet. + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + GNOME zeigt dort App-Symbole nur mit der Erweiterung AppIndicator an. Installiere sie (gnome-shell-extension-appindicator) und schalte sie unter „Erweiterungen“ ein. + + + + Install fuse3 to browse your phone's files in your file manager. + Installiere fuse3, um die Dateien deines Geräts im Dateimanager zu durchsuchen. + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + Installiere gstreamer1.0-pipewire und gstreamer1.0-tools und, falls es fehlt, das Desktop-Portal (xdg-desktop-portal). + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + Installiere pipewire-pulse (oder pulseaudio-utils) für den Ton bei Anrufen und beim Suchen deines PCs. + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + Installiere v4l2loopback-dkms und starte den PC neu, damit Apps dein Gerät als Kamera verwenden können. + + + + It hasn't connected yet. Es war noch nicht verbunden. - + Keep %1 reachable in the background (%2) %1 im Hintergrund erreichbar halten (%2) - + Last seen %1. Zuletzt gesehen %1. - - NectarLink isn't allowed through Windows Firewall + + NectarLink can't show its icon in the top bar + NectarLink kann sein Symbol nicht in der oberen Leiste anzeigen + + + + NectarLink is in the top bar + NectarLink ist in der oberen Leiste + + + + NectarLink isn't allowed through Windows Firewall NectarLink ist in der Windows-Firewall nicht zugelassen - + + NectarLink isn't allowed through the firewall + NectarLink ist in der Firewall nicht zugelassen + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. Auf Huawei / Honor: Öffne Einstellungen → Akku → App-Start, deaktiviere „Automatisch verwalten“ für NectarLink und lasse „Auto-Start“, „Sekundärer Start“ und „Im Hintergrund ausführen“ aktiviert. - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. Auf OnePlus / OxygenOS: Öffne Einstellungen → Apps → App-Verwaltung → NectarLink → Akkunutzung und aktiviere „Hintergrundaktivität zulassen“ sowie „Automatischen Start zulassen“. - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. Auf Oppo / Realme: Öffne Einstellungen → Apps → App-Verwaltung → NectarLink → Akkunutzung und aktiviere „Hintergrundaktivität zulassen“ sowie „Automatischen Start zulassen“. - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. Auf Pixel: Öffne Einstellungen → Apps → Alle Apps ansehen → NectarLink → Akkunutzung der App, tippe auf „Hintergrundnutzung zulassen“ und wähle „Uneingeschränkt“. - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. Auf Samsung: Öffne Einstellungen → Apps → NectarLink → Akku und wähle „Nicht eingeschränkt“. Stelle dann unter Gerätewartung → Akku → Grenzen der Hintergrundnutzung sicher, dass NectarLink nie in den Standby versetzt wird. - + On USB Über USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. Auf Vivo / OriginOS / Funtouch: Öffne Einstellungen → Akku → Hintergrund-Stromverbrauchsverwaltung → NectarLink, wähle „Uneingeschränkt“ und erlaube „Autostart“. - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. Auf Xiaomi / HyperOS / MIUI: Öffne Einstellungen → Apps → Apps verwalten → NectarLink, aktiviere „Autostart“ und stelle das Energiesparen auf „Keine Beschränkungen“. - - On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. + + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. In öffentlichen Netzwerken verhindert Windows, dass andere Geräte diesen PC finden. Wenn es dein Heim- oder Arbeitsnetzwerk ist, stelle es auf „Privat“. - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + In öffentlichen Netzwerken verhindert die Firewall, dass andere Geräte diesen PC finden. Wenn es dein Heim- oder Arbeitsnetzwerk ist, öffne die Netzwerkeinstellungen und setze seine Firewall-Zone auf „Home“ oder „Trusted“. + + + On this network In diesem Netzwerk - + + Phone as webcam is ready + „Gerät als Webcam“ ist bereit + + + + Phone as webcam needs one more step + Für „Gerät als Webcam“ fehlt noch ein Schritt + + + + Phone storage can open in your file manager + Der Gerätespeicher kann im Dateimanager geöffnet werden + + + + Phone storage needs one more step + Für den Gerätespeicher fehlt noch ein Schritt + + + + Showing this PC's screen needs one more step + Für die Anzeige des PC-Bildschirms fehlt noch ein Schritt + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. Bei der Nachfrage von Windows wurde der Zugriff nicht zugelassen. Erlaube ihn, damit sich dein Gerät verbinden kann. - - This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. + + Sound is ready + Ton ist bereit + + + + The firewall blocks NectarLink + Die Firewall blockiert NectarLink + + + + The firewall is off for this network + Die Firewall ist für dieses Netzwerk deaktiviert + + + + The firewall lets your phone connect + Die Firewall lässt Verbindungen deines Geräts zu + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + Touchpad und Tastatur auf deinem Gerät brauchen das Desktop-Portal (xdg-desktop-portal) oder Zugriff auf /dev/uinput. + + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. Dieser PC erkennt %1 im Netzwerk, aber der Router blockiert die direkte Kommunikation zwischen Geräten (oft „AP-Isolation“, „Client-Isolation“ oder „Gast-WLAN“ genannt). Verbinde beide Geräte mit dem normalen WLAN statt eines Gastnetzwerks, deaktiviere die AP-/Client-Isolation in den WLAN-Einstellungen deines Routers oder nutze ein USB-Kabel. - - This PC isn't on a network + + This PC isn't on a network Dieser PC ist mit keinem Netzwerk verbunden - + This network Dieses Netzwerk - + This usually takes a few seconds. Das dauert normalerweise nur ein paar Sekunden. - - VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. + + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. VPNs verhindern oft, dass Geräte im lokalen Netzwerk diesen PC erreichen. Wenn sich dein Gerät nicht verbinden kann, schalte das VPN testweise aus. - + While away Unterwegs - + Windows Firewall blocks NectarLink Die Windows-Firewall blockiert NectarLink - + Windows Firewall is off for this network Die Windows-Firewall ist für dieses Netzwerk deaktiviert - + Windows Firewall lets your phone connect Die Windows-Firewall lässt Verbindungen deines Geräts zu - + + Your phone can control this PC + Dein Gerät kann diesen PC steuern + + + + Your phone can show this PC's screen + Dein Gerät kann den Bildschirm dieses PCs anzeigen + + + + Your phone can't control this PC yet + Dein Gerät kann diesen PC noch nicht steuern + + + Your phone may not be able to connect. Allow it once here. Dein Gerät kann sich möglicherweise nicht verbinden. Erlaube den Zugriff hier einmalig. - + a minute ago vor einer Minute - + an hour ago vor einer Stunde - + just now gerade eben @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow Zulassen - + Open settings Einstellungen öffnen - + Reconnect Neu verbinden - + Connection Doctor Verbindungs-Doktor - + Checking network and firewall… Netzwerk und Firewall werden geprüft… - + Everything looks right. Alles sieht gut aus. - + + Your phone can reach this PC. A few features need a step from you: + Dein Gerät erreicht diesen PC. Für einige Funktionen ist noch ein Schritt von dir nötig: + + + Issues blocking your phone from reaching this PC: Probleme, die verhindern, dass dein Gerät diesen PC erreicht: - + Firewall settings Firewall-Einstellungen - + Check again Erneut prüfen - + Done Fertig @@ -2106,233 +2256,233 @@ Errors - + Something went wrong: %1 Etwas ist schiefgelaufen: %1 - - That device isn't paired anymore. + + That device isn't paired anymore. Dieses Gerät ist nicht mehr gekoppelt. - + The device declined. Das Gerät hat abgelehnt. - - The device didn't answer in time. + + The device didn't answer in time. Das Gerät hat nicht rechtzeitig geantwortet. - - The device isn't connected right now. + + The device isn't connected right now. Das Gerät ist momentan nicht verbunden. - - The device's app doesn't support that yet. + + The device's app doesn't support that yet. Die App auf dem Gerät unterstützt das noch nicht. FeaturesGuidePage - + Overview Übersicht - + Setup Einrichtung - + Permissions Berechtigungen - + Power levels Funktionsmodi - + Troubleshooting Fehlerbehebung - + FAQ FAQ - + All Alle - + Notifications & Calls Benachrichtigungen & Anrufe - + Messages Nachrichten - + Clipboard & Files Zwischenablage & Dateien - + Mirroring & Apps Spiegelung & Apps - + Camera & Audio Kamera & Audio - + Input & Control Eingabe & Steuerung - + Desktop Surfaces Desktop-Oberflächen - + Automation & Network Automatisierung & Netzwerk - - - - + + + + Ready on PC Bereit auf dem PC - - - + + + Off in Settings In Einstellungen deaktiviert - + Opt-in on Home Auf der Startseite aktivierbar - - + + Off on Home Auf der Startseite deaktiviert - - + + Ready Bereit - + Basic mode Basismodus - - + + Needs phone permission Geräteberechtigung erforderlich - + Needs Elevated Benötigt Erweitert - + Needs Assist Benötigt Assistenz - + Every feature in NectarLink, what it needs, and where to find it. Jede Funktion in NectarLink, was sie benötigt und wo du sie findest. - + Read the full guide › Vollständiges Handbuch lesen › - + Setup walkthrough Einrichtungsassistent - + Connect your phone to see which features are ready Verbinde dein Gerät, um zu sehen, welche Funktionen bereit sind - + Search features by name, location, or permission... Funktionen nach Name, Ort oder Berechtigung durchsuchen… - + Clear search Suche löschen - + Show me Zeig es mir - + Learn more › Mehr erfahren › - + Where: Wo: - + Needs: Benötigt: - + No matching features Keine passenden Funktionen - + Try another search term or select All above. Versuche einen anderen Suchbegriff oder wähle oben „Alle“. - + Close guide Handbuch schließen - + Close Schließen @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. Ordner sind auf dem neuesten Stand. - + Synced %1 file(s). %1 Datei(en) synchronisiert. @@ -2353,85 +2503,85 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. 5. Choose which features to share for that phone and grant only the Android permissions you want to use. - 1. Installiere NectarLink auf deinem Windows-11-PC und deinem Android-Gerät (Android 8.0 oder neuer). + 1. Installiere NectarLink auf deinem PC (Windows 11 oder Linux) und deinem Android-Gerät (Android 8.0 oder neuer). 2. Verbinde beide Geräte mit demselben WLAN oder schließe dein Gerät per USB-Kabel an deinen PC an. 3. Öffne den Kopplungsschritt im Einrichtungsassistenten oder klicke unten in der linken Seitenleiste auf „+“ (Neues Gerät koppeln). 4. Scanne den QR-Code in der NectarLink-Android-App oder wähle dein Gerät aus der Liste der Geräte in der Nähe aus und bestätige, dass der 6-stellige Code auf beiden Bildschirmen übereinstimmt. 5. Wähle aus, welche Funktionen für dieses Gerät freigegeben werden sollen, und erteile nur die Android-Berechtigungen, die du nutzen möchtest. - + Android permissions and why each is optional Android-Berechtigungen und warum jede optional ist - + Automations Automatisierungen - + Calls Anrufe - + Clipboard Zwischenablage - + Command palette Befehlspalette - + Connection Doctor Verbindungs-Doktor - + Continuity camera Übergabekamera - + Deck Deck - + Drop shelf Shelf - + Elevated and Assist modes Erweiterter und Assistenz-Modus - + Features guide Funktionsübersicht - + Features reference Funktionsreferenz - + Handoff Übergabe (Handoff) - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2446,17 +2596,17 @@ 4. Du kannst die Verbindung auch über ein USB-Kabel bei aktiviertem USB-Debugging herstellen; NectarLink richtet dann automatisch einen lokalen Tunnel ein. - + Install and first-time setup Installation und Ersteinrichtung - + Local HTTP API Lokale HTTP-API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2475,17 +2625,17 @@ Freigabeeinstellungen pro Gerät Sensible Funktionen wie „PC-Ordner freigeben“, „Ferneingabe“ und die umgekehrte PC-Bildschirmspiegelung sind für jedes gekoppelte Gerät standardmäßig deaktiviert, bis du sie einschaltest. - + LocalSend LocalSend - + Messages Nachrichten - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2508,12 +2658,12 @@ Assistenz-Modus (Android-Bedienungshilfen) - NectarLink liest über die Bedienungshilfen niemals Bildschirmtexte aus und zeichnet keine Tastenanschläge auf. - + Nectar Island Nectar Island - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2538,7 +2688,7 @@ Funktionsstufen unter Android - Assistenz-Modus (optional): Nutzt die Android-Bedienungshilfen nur dann, wenn du die Ferneingabe aktivierst, damit Maus und Tastatur deines PCs das Gerät steuern können. - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2555,97 +2705,97 @@ Funktionsstufen unter Android - Kamera und Mikrofon: Nur erforderlich, während du dein Gerät als Webcam nutzt, ein Foto per Continuity-Kamera aufnimmst oder eine Sprachnotiz aufzeichnest. - + NectarLink overview NectarLink-Übersicht - + Notifications Benachrichtigungen - + Phone as speaker Gerät als Lautsprecher - + Phone as webcam Gerät als Webcam - + Phone controls Gerätesteuerung - + Photos Fotos - + Privacy and security Datenschutz und Sicherheit - + Remote input Ferneingabe - + Screen mirroring Bildschirmspiegelung - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. Wähle eine beliebige Funktionskarte in der Funktionsübersicht aus, um ihren Live-Status zu prüfen, mit „Zeig es mir“ direkt zur Steuerung zu springen oder nachzulesen, wie sie funktioniert und welche Berechtigungen sie nutzt. - + Send files Dateien senden - + Share PC folders PC-Ordner freigeben - + Share PC screen PC-Bildschirm teilen - + Timeline Zeitverlauf - + Troubleshooting and Connection Doctor Fehlerbehebung und Verbindungs-Doktor - + Type on phone Auf dem Gerät tippen - + Voice recorder Sprachrekorder - + Wake PC PC aufwecken - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2666,9 +2816,9 @@ Voraussetzungen - Erteilte Foto- und Medienberechtigung auf deinem Android-Gerät. - + What it does -Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. +Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. Where to find it - Home › Phone controls and Find phone. @@ -2687,7 +2837,7 @@ Voraussetzungen - Lautstärke, Taschenlampe und „Mein Gerät finden“ funktionieren im Standardmodus; „Nicht stören“ erfordert Zugriff auf die „Nicht stören“-Einstellungen; WLAN- und Bluetooth-Schalter nutzen unter neueren Android-Versionen den erweiterten Modus. - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2708,7 +2858,7 @@ Voraussetzungen - „LocalSend im lokalen Netzwerk“ unter Einstellungen › Dateien & Sync aktiviert sowie beide Geräte im selben lokalen Netzwerk. - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2727,7 +2877,7 @@ Voraussetzungen - Aktiviere Nectar Island unter Einstellungen › Allgemein und wähle aus, welche Ereignisse angezeigt werden sollen. - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2746,7 +2896,7 @@ Voraussetzungen - Aktiviere „Lokale Automatisierungs-API“ unter Einstellungen › Verbindung & Info und lies das Bearer-Token aus api-token.json aus. - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2767,7 +2917,7 @@ Voraussetzungen - Assistenz-Modus (Bedienungshilfen), die NectarLink-Eingabemethode oder den erweiterten Modus auf deinem Gerät. - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2788,7 +2938,7 @@ Voraussetzungen - Jederzeit auf deinem PC verfügbar. - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2809,7 +2959,7 @@ Voraussetzungen - Datei- und Medienzugriff auf deinem Gerät sowie mindestens eine eingerichtete Ordnersynchronisierungsregel in den Einstellungen. - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2832,7 +2982,7 @@ Voraussetzungen - Benachrichtigungszugriff auf deinem Gerät für Unterhaltungen aus Messenger-Apps. - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2853,7 +3003,7 @@ Voraussetzungen - Ein Netzwerkadapter mit aktiviertem Wake-on-LAN im Windows-Geräte-Manager sowie im BIOS/UEFI (kabelgebundenes Ethernet ist am zuverlässigsten). - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2874,7 +3024,7 @@ Voraussetzungen - Der gerätespezifische Schalter „Fernsteuerung per Maus, Tastatur & Deck“ auf deinem PC (oder eine einmalige Bestätigung, wenn dein Gerät die Steuerung anfordert). - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2899,7 +3049,7 @@ Voraussetzungen - Aktivierter Schalter „Benachrichtigungen“ für dieses Gerät auf deinem PC. - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2920,9 +3070,9 @@ Voraussetzungen - Jederzeit einsatzbereit, ohne zusätzlichen Speicherbedarf im Hintergrund, wenn sie geschlossen ist. - + What it does -Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. +Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. Where to find it - Global hotkey Ctrl+Alt+S. @@ -2943,7 +3093,7 @@ Voraussetzungen - Gekoppeltes Gerät; Foto-Berechtigung auf deinem Gerät für die Kacheln „Neuestes Foto“ und „Neuester Screenshot“. - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2964,7 +3114,7 @@ Voraussetzungen - Funktioniert automatisch für alle gekoppelten Geräte; die Aufbewahrungsdauer kann unter Einstellungen › Speicher & Datenschutz angepasst werden. - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2985,7 +3135,7 @@ Voraussetzungen - Mikrofonberechtigung auf deinem Gerät beim Aufnehmen einer Sprachnotiz. - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -3004,7 +3154,7 @@ Voraussetzungen - Füge dort eine oder mehrere Regeln hinzu und aktiviere sie. - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -3025,7 +3175,7 @@ Voraussetzungen - Gekoppeltes Gerät und aktivierter Schalter „Dateien“ für dieses Gerät. - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -3044,7 +3194,7 @@ Voraussetzungen - Gekoppeltes Gerät; funktioniert online sofort oder reiht die Übergabe automatisch in die Warteschlange ein, wenn dein Gerät offline ist. - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -3067,7 +3217,7 @@ Voraussetzungen - Die Hintergrundsynchronisierung vom Gerät zum PC nutzt ab Android 10 Benachrichtigungen, die NectarLink-Schnelleinstellungskachel oder den erweiterten Modus. - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -3090,7 +3240,7 @@ Voraussetzungen - Das Gesprächsaudio bleibt auf deinem Gerät (oder einem gekoppelten Headset); der erweiterte Modus (Elevated) schaltet ab Android 12 Stummschalten, Freisprechen, Halten und das Tastenfeld frei. - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -3111,9 +3261,9 @@ Voraussetzungen - Gekoppeltes Gerät, das über lokales WLAN oder USB verbunden ist. - + What it does -Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. +Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. Where to find it - Started from the NectarLink app on your phone. @@ -3132,9 +3282,9 @@ Voraussetzungen - Der gerätespezifische Schalter „PC-Bildschirm für Gerät freigeben“ auf deinem PC oder eine einmalige Bestätigung, wenn dein Gerät die Bildschirmansicht anfordert. - + What it does -Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. +Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. Where to find it - Home › Phone as webcam. @@ -3155,7 +3305,7 @@ Voraussetzungen - Damit andere Apps sie erkennen: „Virtuelle Kamera (Add-on)“ unter Einstellungen › Kamera & Audio. - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -3164,7 +3314,7 @@ Where to find it - Home › Mirror screen quick action. What it needs -- Standard mode mirrors the full phone screen after you tap Start now on Android's screen capture prompt. +- Standard mode mirrors the full phone screen after you tap Start now on Android's screen capture prompt. - Elevated mode (Wireless debugging or root) skips the prompt and unlocks individual app windows. - Assist mode or Elevated mode lets you click, scroll, and type inside the mirrored window. Funktion @@ -3180,7 +3330,7 @@ Voraussetzungen - Mit dem Assistenz-Modus oder dem erweiterten Modus kannst du im gespiegelten Fenster klicken, scrollen und tippen. - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -3199,7 +3349,7 @@ Voraussetzungen - Gekoppeltes Gerät über eine beliebige aktive Verbindung. - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3223,12 +3373,12 @@ Voraussetzungen HistoryItem - + Open %1 in an app window %1 in einem App-Fenster öffnen - + Yesterday %1 Gestern %1 @@ -3236,47 +3386,47 @@ Voraussetzungen HomePage - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Send files via LocalSend Dateien über LocalSend senden - + never nie - + just now gerade eben - + %n min ago vor %n Min. @@ -3284,7 +3434,7 @@ Voraussetzungen - + %n h ago vor %n Std. @@ -3292,812 +3442,822 @@ Voraussetzungen - + ~%1 min to full ~%1 Min. bis voll - + ~%1h %2m to full ~%1 Std. %2 Min. bis voll - + ~%1h to full ~%1 Std. bis voll - + New: Features guide Neu: Funktionsübersicht - + See what each feature needs and where to find it. Erfahre, was jede Funktion benötigt und wo du sie findest. - - - + + + Dismiss Schließen - + Open features guide Funktionsübersicht öffnen - + Try Hive, the new theme Probiere „Wabe“, das neue Design - + Honey on charcoal or warm wax, with a living honeycomb behind the app. Change it any time in Settings › Appearance. Honig auf Anthrazit oder warmem Wachs, mit einer lebendigen Bienenwabe hinter der App. Jederzeit unter Einstellungen › Darstellung änderbar. - + Not now Jetzt nicht - + Try Hive Wabe ausprobieren - + No phone paired yet Noch kein Gerät gekoppelt - + Pair your Android phone over Wi-Fi or USB. Kopple dein Android-Gerät über WLAN oder USB. - + Features guide Funktionsübersicht - + Setup walkthrough Einrichtungsassistent - + Pair a phone Gerät koppeln - - + + Transfers Übertragungen - - + + Clear finished Abgeschlossene löschen - + Drop to send to %1 Hier ablegen, um an %1 zu senden - + Hold Shift while dropping a document to open it on arrival Halte beim Ablegen eines Dokuments die Umschalttaste gedrückt, um es nach dem Empfang zu öffnen - - + + Charging Lädt - + Low Niedrig - + Android %1 Android %1 - + Away · %1 ms Abwesend · %1 ms - + Away Abwesend - + USB · %1 ms USB · %1 ms - + USB USB - - + + Wi‑Fi · %1 ms WLAN · %1 ms - - - - + + + + Wi‑Fi WLAN - + Cellular Mobilfunk - + · %1 ms · %1 ms - + Offline · %1 Offline · %1 - + Offline Offline - + Troubleshoot Problembehandlung - + Media player Mediaplayer - + Nothing playing Keine Wiedergabe - + Mirror screen Bildschirm spiegeln - + Open an app App öffnen - - + + Send files Dateien senden - + Send a folder Ordner senden - + Send clipboard Zwischenablage senden - - + + Clipboard history Zwischenablageverlauf - + Stop ringing Klingeln beenden - + Find phone Gerät klingeln lassen - + Send to %1 An %1 senden - + Hand off & open on %1 Übergeben & auf %1 öffnen - + Send a folder to %1 Ordner an %1 senden - + Waiting to send · %1 Wartet auf Senden · %1 - + Waiting to send Wartet auf Senden - + Clear Leeren - + Cancel all Alle abbrechen - + Sending to %1 · %2% Wird an %1 gesendet · %2% - + Sending to %1… Wird an %1 gesendet… - + Failed · %1 Fehlgeschlagen · %1 - + Failed Fehlgeschlagen - + Expired Abgelaufen - + Waiting for %1 · %2 Wartet auf %1 · %2 - + Waiting for %1 Wartet auf %1 - + Retry Erneut versuchen - - - + + + Cancel Abbrechen - - + + Timeline Zeitverlauf - + Notifications · %1 Benachrichtigungen · %1 - + Notifications Benachrichtigungen - + History Verlauf - + Notification history (last 24 hours) Benachrichtigungsverlauf (letzte 24 Stunden) - + Clear all Alle löschen - + Undo Rückgängig - + No notifications Keine Benachrichtigungen - + Notifications unavailable Benachrichtigungen nicht verfügbar - + Connect %1 to view notifications Verbinde %1, um Benachrichtigungen anzuzeigen - + + Desktop notifications are off. Turn them on in your system's notification settings. + Desktop-Benachrichtigungen sind deaktiviert. Schalte sie in den Benachrichtigungseinstellungen deines Systems ein. + + + Windows notifications are off. Windows-Benachrichtigungen sind deaktiviert. - + Turn on Aktivieren - + Webcam Webcam - + Streaming from %1 Streamt von %1 - - + + Stop Stoppen - + Back Hinten - + Front Vorne - + Mirror Spiegeln - + Phone controls Gerätesteuerung - + Less Weniger - + Sound & display Ton & Anzeige - + DND Nicht stören - + Do Not Disturb Nicht stören - + Torch Licht - + Flashlight Taschenlampe - + BT BT - + Bluetooth Bluetooth - + Hotspot Hotspot - + Instant Hotspot Instant Hotspot - + Phone as speaker Gerät als Lautsprecher - + Streaming PC audio PC-Audio wird gestreamt - + Play on phone Auf dem Gerät abspielen - + Watching on %1 Wiedergabe auf %1 - + Tap the notification on %1 Tippe auf die Benachrichtigung auf %1 - + Play a video on this PC first Spiele zuerst ein Video auf diesem PC ab - + Turning off Wi‑Fi may disconnect %1. Das Ausschalten von WLAN kann die Verbindung zu %1 trennen. - + Turn off Ausschalten - + Ring Klingeln - + Vibrate Vibrieren - + Silent Lautlos - + Volume Lautstärke - + Brightness Helligkeit - + Continuity Camera Übergabekamera - + Scan document Dokument scannen - + Phone as webcam Gerät als Webcam - + Streaming Streamt - + Type on phone Auf dem Gerät tippen - + Sent: %1 Gesendet: %1 - + Listening for keys Tastatureingabe aktiv - - + + When connected Wenn verbunden - + Start Starten - - + + Clipboard Zwischenablage - + Type here: keys go to %1 Hier tippen: Eingaben gehen an %1 - + Click to focus keyboard Klicken, um Tastaturfokus zu setzen - + Ctrl+V pastes · Esc stops Strg+V fügt ein · Esc beendet - + Paste Einfügen - + View all Alle anzeigen - + No recent activity Keine kürzlichen Aktivitäten - + %1 notifications %1-Benachrichtigungen - + Show Anzeigen - + No pop-ups Keine Pop-ups - + Hide Ausblenden - + Hidden on this PC. Auf diesem PC ausgeblendet. - + Shown in feed without pop-ups. Wird im Feed ohne Pop-ups angezeigt. - + + Shown in feed and as desktop pop-ups. + Wird im Feed und als Desktop-Pop-up angezeigt. + + + Shown in feed and as Windows pop-ups. Wird im Feed und als Windows-Pop-up angezeigt. - - - - + + + + Done Fertig - + Notification history Benachrichtigungsverlauf - - + + Clear history Verlauf löschen - + Dismissed notifications from the last 24 hours. Verworfene Benachrichtigungen der letzten 24 Stunden. - + No recent notifications. Keine kürzlichen Benachrichtigungen. - - + + Search notification history Benachrichtigungsverlauf durchsuchen - - + + Nothing matches “%1”. Keine Treffer für „%1“. - + Last 50 shared clips. Die letzten 50 geteilten Einträge der Zwischenablage. - + No shared clips yet. Noch keine geteilten Einträge in der Zwischenablage. - - + + Search clipboard history Zwischenablageverlauf durchsuchen - + From %1 Von %1 - + From phone Vom Gerät - + Sent to %1 An %1 gesendet - + From this PC Von diesem PC - + Image Bild - + Pinned Angeheftet - + Open on phone Auf dem Gerät öffnen - + Send to phone An Gerät senden - + Copy Kopieren - + Unpin Lösen - + Pin Anheften - + Delete Löschen - - + + %1% %1% - + LocalSend nearby · %1 LocalSend in der Nähe · %1 - + LocalSend nearby LocalSend in der Nähe - + Refresh Aktualisieren - + Listening for LocalSend devices on your Wi‑Fi network. Sucht nach LocalSend-Geräten in deinem WLAN. - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). LocalSend-Port 53317 ist auf diesem PC belegt (möglicherweise läuft eine andere LocalSend-App). - + Nearby device Gerät in der Nähe - + Image preview Bildvorschau - + Close Schließen - + Open externally Extern öffnen @@ -4105,112 +4265,112 @@ Voraussetzungen Hotspot - - %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. + + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. Der Hotspot von %1 wurde nicht gefunden. Prüfe, ob er eingeschaltet ist, und wähle dann %2 in der WLAN-Liste von Windows aus. - - Connected to %1's hotspot. + + Connected to %1's hotspot. Mit dem Hotspot von %1 verbunden. - - Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. + + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. Verbindung zum Hotspot von %1 fehlgeschlagen. Wähle %2 in der WLAN-Liste von Windows aus. IslandWindow - + Nectar Island Nectar Island - + Phone call Telefonanruf - + Transfer complete Übertragung abgeschlossen - + Transferring %1 file(s)… %1 Datei(en) werden übertragen… - + Now playing Aktuelle Wiedergabe - + %1 phone notification(s) %1 Gerätebenachrichtigung(en) - - - + + + %1 waiting %1 ausstehend - + Nectar Island · %1 Nectar Island · %1 - + Nectar Island · Ready Nectar Island · Bereit - + Incoming call Eingehender Anruf - + On hold Gehalten - + Call in progress Laufender Anruf - + This PC Dieser PC - + Phone Gerät - + Click to view in NectarLink Klicken, um in NectarLink anzuzeigen - + Will send when %1 is back Wird gesendet, sobald %1 wieder da ist - + Will send when connected Wird gesendet, sobald verbunden - + Live calls, transfers & media Aktive Anrufe, Übertragungen & Medien @@ -4218,62 +4378,62 @@ Voraussetzungen Links - + %1: tap the notification on your phone to continue watching. %1: Tippe auf die Benachrichtigung auf deinem Gerät, um weiterzuschauen. - + %1: tap the notification on your phone to open in Maps. %1: Tippe auf die Benachrichtigung auf deinem Gerät, um sie in Maps zu öffnen. - + Copy a link or address first Kopiere zuerst einen Link oder eine Adresse - + Copy a web link (https://…), geo: URI, or street address, then choose this again. Kopiere einen Weblink (https://…), eine geo:-URI oder eine Adresse und wähle diese Option erneut aus. - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. Kopiere einen Weblink, eine Video-URL oder eine Adresse und wähle diese Option erneut aus, um sie auf deinem Gerät zu öffnen. - - Couldn't send it + + Couldn't send it Senden fehlgeschlagen - + No phone to open it on Kein Gerät zum Öffnen vorhanden - + Pair a phone first, or enable opening links on your phone. Kopple zuerst ein Gerät oder aktiviere das Öffnen von Links auf deinem Gerät. - + Sent to %1 An %1 gesendet - + Tap the notification on your phone to open it. Tippe auf die Benachrichtigung auf deinem Gerät, um sie zu öffnen. - - That's not a link or address + + That's not a link or address Das ist kein Link und keine Adresse - + Update NectarLink on %1. Aktualisiere NectarLink auf %1. @@ -4281,107 +4441,107 @@ Voraussetzungen LockChip - + Elevated Erweitert - + Assist Assistenz - + Notification access Benachrichtigungszugriff - + DND access Nicht-stören-Zugriff - + System settings Systemeinstellungen - + Permission needed Berechtigung erforderlich - + Add-on Add-on - + Connect over mobile data Über mobile Daten verbinden - + Off Aus - + Update app App aktualisieren - + Unavailable Nicht verfügbar - + On your phone, open NectarLink › Settings › Control from your PC and turn on the Accessibility service. Öffne auf deinem Gerät NectarLink › Einstellungen › Vom PC aus steuern und aktiviere die Bedienungshilfe. - + Needs Elevated: set up Wireless debugging or connect over USB. Erfordert den erweiterten Modus: Richte Kabelloses Debugging ein oder verbinde das Gerät über USB. - + On your phone, set NectarLink as the default SMS/Caller ID app when prompted. Lege NectarLink auf deinem Gerät als Standard-App für SMS/Anrufer-ID fest, wenn du dazu aufgefordert wirst. - + On your phone, open NectarLink and allow Notification access. Öffne NectarLink auf deinem Gerät und erlaube den Benachrichtigungszugriff. - + On your phone, open NectarLink and allow Do Not Disturb access. Öffne NectarLink auf deinem Gerät und erlaube den Zugriff auf „Nicht stören“. - + On your phone, open NectarLink and allow Modify system settings. Öffne NectarLink auf deinem Gerät und erlaube das Ändern von Systemeinstellungen. - + Open NectarLink on your phone and grant this permission in Settings. Öffne NectarLink auf deinem Gerät und erteile diese Berechtigung in den Einstellungen. - + Open Settings › Camera & Audio to set up the NectarLink virtual camera add-on. Öffne Einstellungen › Kamera & Audio, um das Add-on für die virtuelle NectarLink-Kamera einzurichten. - + Open Settings › Connection & About and turn on Connect over mobile data. Öffne Einstellungen › Verbindung & Info und aktiviere „Über mobile Daten verbinden“. - + This feature is turned off for this phone in Settings › Devices. Diese Funktion ist für dieses Gerät unter Einstellungen › Geräte deaktiviert. @@ -4389,177 +4549,177 @@ Voraussetzungen MainWindow - - + + Home Start - - + + Messages Nachrichten - - + + Calls Anrufe - - + + Photos Fotos - - + + Apps Apps - - + + Deck Deck - + Guide Anleitung - - + + Settings Einstellungen - + Pair a new device Neues Gerät koppeln - + Back to Home Zurück zu Start - - + + Timeline Zeitverlauf - + Features guide Funktionsübersicht - + Sharing screen with %1 Bildschirm wird mit %1 geteilt - + Sharing screen Bildschirm wird geteilt - + Stop Stoppen - - Stop sharing this PC's screen + + Stop sharing this PC's screen Bildschirmfreigabe dieses PCs beenden - + Commands Befehle - + Command palette (%1) Befehlspalette (%1) - + Command palette (Ctrl+K) Befehlspalette (Strg+K) - + Files, clips, links, photos, recordings and sessions Dateien, Zwischenablage, Links, Fotos, Aufnahmen und Sitzungen - + Refresh Aktualisieren - + %1 is ringing this PC %1 lässt diesen PC klingeln - + Stop ringing Klingeln beenden - + Allow %1 to control this PC? Darf %1 diesen PC steuern? - - %1 wants to use this PC's mouse and keyboard. You can change this in Settings. + + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. %1 möchte Maus und Tastatur dieses PCs verwenden. Du kannst dies in den Einstellungen ändern. - - + + Not now Jetzt nicht - - + + Allow Erlauben - - Allow %1 to view this PC's screen? + + Allow %1 to view this PC's screen? Darf %1 den Bildschirm dieses PCs sehen? - - %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. + + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. %1 möchte den Bildschirm dieses PCs sehen. Für die Steuerung muss in den Einstellungen „Remote-Maus, -Tastatur & Deck“ aktiviert sein. - + Allow for this device Für dieses Gerät erlauben - + Photo ready on clipboard Foto in der Zwischenablage bereit - + Keep on clipboard In der Zwischenablage behalten - + Paste into active window In aktives Fenster einfügen @@ -4567,35 +4727,35 @@ Voraussetzungen Media - + Media is turned off for that device. Medien sind für dieses Gerät deaktiviert. - - That player can't do that. + + That player can't do that. Dieser Player unterstützt diese Aktion nicht. - - That player isn't playing anymore. + + That player isn't playing anymore. Dieser Player gibt nichts mehr wieder. Messages - + Attachment is %1 (MMS supports up to 900 KB) Anhang ist %1 groß (MMS unterstützt bis zu 900 KB) - - Group texts can't be sent from the PC yet. + + Group texts can't be sent from the PC yet. Gruppennachrichten können noch nicht vom PC aus gesendet werden. - + Only image or voice-note audio files can be attached. Es können nur Bilddateien oder Sprachnotiz-Audiodateien angehängt werden. @@ -4603,240 +4763,240 @@ Voraussetzungen MessagesPage - + Attach image or voice note Bild oder Sprachnotiz anhängen - + Media (*.png *.jpg *.jpeg *.webp *.gif *.bmp *.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) Medien (*.png *.jpg *.jpeg *.webp *.gif *.bmp *.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) - + Images (*.png *.jpg *.jpeg *.webp *.gif *.bmp) Bilder (*.png *.jpg *.jpeg *.webp *.gif *.bmp) - + Audio (*.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) Audio (*.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) - + Today Heute - - + + Yesterday Gestern - + Text from your PC Nachrichten vom PC schreiben - - %1 isn't connected + + %1 isn't connected %1 ist nicht verbunden - + Messages are off for %1 Nachrichten sind für %1 deaktiviert - + Allow texting on %1 Nachrichten auf %1 erlauben - - Couldn't load messages + + Couldn't load messages Nachrichten konnten nicht geladen werden - + Pair your phone to read and send texts. Kopple dein Gerät, um SMS zu lesen und zu senden. - + Connect %1 to view messages. Verbinde %1, um Nachrichten anzuzeigen. - + Enable Messages in Settings. Aktiviere „Nachrichten“ in den Einstellungen. - + Grant SMS permission on %1. Erteile die SMS-Berechtigung auf %1. - + Unable to load messages. Nachrichten können nicht geladen werden. - + Try again Erneut versuchen - + Conversations Unterhaltungen - + New message (Ctrl+N) Neue Nachricht (Strg+N) - + Refresh Aktualisieren - + Search conversations Unterhaltungen durchsuchen - + Search Suchen - + Clear search Suche löschen - + No conversations match. Keine passenden Unterhaltungen gefunden. - + No conversations yet. Noch keine Unterhaltungen. - + Drop image to attach (MMS) Bild zum Anhängen hier ablegen (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB PNG, JPEG, WebP, GIF oder BMP bis zu 900 KB - + Select a conversation Wähle eine Unterhaltung aus - + Back to conversations (Esc) Zurück zu den Unterhaltungen (Esc) - + Call %1 %1 anrufen - + Call Anrufen - + Remove conversation from PC Unterhaltung vom PC entfernen - + To An - - + + Name or phone number Name oder Telefonnummer - + No mirrored messages yet Noch keine gespiegelten Nachrichten - + No messages Keine Nachrichten - + %1 · Sent as MMS %1 · Als MMS gesendet - + Remove attachment Anhang entfernen - + Attach image (MMS) Bild anhängen (MMS) - + Reply in %1 In %1 antworten - - + + Text message SMS-Nachricht - + Replies are available while the notification is active on your phone Antworten sind möglich, solange die Benachrichtigung auf deinem Gerät aktiv ist - + Reply in %1… In %1 antworten… - - Group texts can't be sent from the PC yet + + Group texts can't be sent from the PC yet Gruppennachrichten können noch nicht vom PC aus gesendet werden - + Add a caption (optional)… Bildunterschrift hinzufügen (optional)… - + Send Senden - + %1 characters · %n text(s) %1 Zeichen · %n SMS @@ -4844,72 +5004,72 @@ Voraussetzungen - + Open image attachment Bildanhang öffnen - + Voice note Sprachnotiz - + · Click to play · Klicken zum Abspielen - + · Loading… · Lädt… - + Attachment (open it on the phone) Anhang (auf dem Gerät öffnen) - + Not sent Nicht gesendet - + Sending… Wird gesendet… - + Copy text Text kopieren - + Copied Kopiert - + Copy code %1 Code %1 kopieren - + Image attachment Bildanhang - + Close Schließen - + Open externally Extern öffnen - + Done Fertig @@ -4917,102 +5077,117 @@ Voraussetzungen Mirror - - Couldn't start recording. + + Couldn't start recording. Aufnahme konnte nicht gestartet werden. - + Mirror recording Aufnahme der Spiegelung - + Mirror screenshot Screenshot der Spiegelung - + Mirroring is turned off for this phone. Die Bildschirmspiegelung ist für dieses Gerät deaktiviert. - + + Recording from %1, saved in Downloads/Nectarlink + Aufnahme von %1, gespeichert unter Downloads/Nectarlink + + + Recording from %1, saved in Downloads\Nectarlink Aufnahme von %1, gespeichert unter Downloads\Nectarlink - + Saved %1 %1 gespeichert - + + Saved %1 to Downloads/Nectarlink. + %1 unter Downloads/Nectarlink gespeichert. + + + Saved %1 to Downloads\Nectarlink. %1 unter Downloads\Nectarlink gespeichert. - + Screenshot copied to clipboard. Screenshot in die Zwischenablage kopiert. - + + Screenshot from %1, saved in Downloads/Nectarlink + Screenshot von %1, gespeichert unter Downloads/Nectarlink + + + Screenshot from %1, saved in Downloads\Nectarlink Screenshot von %1, gespeichert unter Downloads\Nectarlink - + Sending %1 files… %1 Dateien werden gesendet… - + Sending %1… %1 wird gesendet… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. Richte Kabelloses Debugging in NectarLink auf dem Gerät ein, um dessen Apps hier zu öffnen. - + Show in folder Im Ordner anzeigen - - The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. + + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. Die App wurde auf dem Gerät nicht geöffnet. Versuche es erneut oder öffne NectarLink auf dem Gerät, um das Kabellose Debugging zu prüfen. - - The phone isn't connected. + + The phone isn't connected. Das Gerät ist nicht verbunden. - - The screenshot couldn't be copied to the clipboard. + + The screenshot couldn't be copied to the clipboard. Der Screenshot konnte nicht in die Zwischenablage kopiert werden. - - The screenshot couldn't be saved. + + The screenshot couldn't be saved. Der Screenshot konnte nicht gespeichert werden. - - This PC can't decode the phone's video. + + This PC can't decode the phone's video. Dieser PC kann das Video des Geräts nicht dekodieren. - - This phone's app doesn't share its screen yet. Update NectarLink on the phone. + + This phone's app doesn't share its screen yet. Update NectarLink on the phone. Die App auf diesem Gerät unterstützt noch keine Bildschirmfreigabe. Aktualisiere NectarLink auf dem Gerät. - + Wait for the screen to appear first. Warte zuerst, bis der Bildschirm angezeigt wird. @@ -5020,143 +5195,143 @@ Voraussetzungen MirrorWindow - + %1 · %2 %1 · %2 - + %1 · NectarLink %1 · NectarLink - + Phone screen · NectarLink Gerätebildschirm · NectarLink - + Copy screenshot (Ctrl+Shift+C) Screenshot kopieren (Strg+Umschalt+C) - + Save screenshot (Ctrl+S) Screenshot speichern (Strg+S) - + Stop recording (Ctrl+R) Aufnahme beenden (Strg+R) - + Record video (Ctrl+R) Video aufnehmen (Strg+R) - + Phone stays awake while mirroring (turn off) Gerät bleibt beim Spiegeln wach (ausschalten) - + Keep phone awake while mirroring Gerät beim Spiegeln wach halten - + Turn phone screen back on Gerätebildschirm wieder einschalten - + Turn phone screen off while mirroring Gerätebildschirm beim Spiegeln ausschalten - - - Play the phone's sound here + + + Play the phone's sound here Ton des Geräts hier abspielen - - Mute the phone's sound here + + Mute the phone's sound here Ton des Geräts hier stummschalten - + Release to send files to %1 Loslassen, um Dateien an %1 zu senden - + phone Gerät - - To control the phone, turn on Control from your PC in NectarLink's settings on the phone. + + To control the phone, turn on Control from your PC in NectarLink's settings on the phone. Um das Gerät zu steuern, aktiviere „Vom PC aus steuern“ in den NectarLink-Einstellungen auf dem Gerät. - + Notify phone Hinweis auf Gerät senden - + Hide Ausblenden - + Opening %1 %1 wird geöffnet - + Allow it on your phone Auf deinem Gerät erlauben - + %1 closed %1 geschlossen - + Mirroring stopped Spiegelung beendet - + Starting… Wird gestartet… - + Tap the notification on %1, then choose to share the entire screen. Tippe auf die Benachrichtigung auf %1 und wähle dann die Freigabe des gesamten Bildschirms aus. - + The app closed on %1. Die App wurde auf %1 geschlossen. - + The phone stopped sharing its screen. Das Gerät hat die Bildschirmfreigabe beendet. - + Open again Erneut öffnen - + Try again Erneut versuchen @@ -5164,7 +5339,7 @@ Voraussetzungen Mp4 - + No video frames were recorded yet. Es wurden noch keine Videobilder aufgenommen. @@ -5172,22 +5347,22 @@ Voraussetzungen NotificationItem - + LIVE LIVE - + Open %1 in an app window %1 in einem App-Fenster öffnen - + now jetzt - + %n min %n Min. @@ -5195,63 +5370,63 @@ Voraussetzungen - + Sending: %1 Wird gesendet: %1 - + You: %1 Du: %1 - + Copy code Code kopieren - + Reply Antworten - - + + Reply to %1 An %1 antworten - + Send Senden - + Show less Weniger anzeigen - + +%1 more +%1 weitere - + Clear %1 %1 löschen - + Options for %1 Optionen für %1 - + Dismiss %1 notifications from %2 %1 Benachrichtigungen von %2 verwerfen - + Dismiss Verwerfen @@ -5259,37 +5434,47 @@ Voraussetzungen Notifications - + Code copied Code kopiert - + Copy code Code kopieren - + Dismissed %1 from %2 %1 von %2 verworfen - + Dismissed %1 notifications %1 Benachrichtigungen verworfen - + Dismissed from %1 Von %1 verworfen - + Notification dismissed Benachrichtigung verworfen - + + Open + Öffnen + + + + Reply + Antworten + + + That notification is gone from the phone. Diese Benachrichtigung ist nicht mehr auf dem Gerät vorhanden. @@ -5297,107 +5482,107 @@ Voraussetzungen NowPlaying - + This PC Dieser PC - - + + Phone Gerät - + PC PC - + Now playing Aktuelle Wiedergabe - + Play on %1 Auf %1 abspielen - + Artwork color sync is on (click to turn off) Farbanpassung an das Albumcover ist aktiv (klicken zum Ausschalten) - + Sync app colors & blur to playing artwork App-Farben & Unschärfe an aktuelles Albumcover anpassen - + Compact player Kompakter Player - + More controls Weitere Steuerelemente - - + + Previous Vorheriger Titel - - + + Pause Pause - - + + Play Wiedergabe - - + + Next Nächster Titel - + Playback position Wiedergabeposition - + Rewind 10 seconds 10 Sekunden zurückspulen - + Forward 10 seconds 10 Sekunden vorspulen - + Unmute Stummschaltung aufheben - + Mute Stummschalten - + Phone speaker Gerätelautsprecher - + PC speaker PC-Lautsprecher @@ -5405,180 +5590,180 @@ Voraussetzungen Obs - - OBS isn't reachable on port %1. Turn on its WebSocket server. + + OBS isn't reachable on port %1. Turn on its WebSocket server. OBS ist auf Port %1 nicht erreichbar. Aktiviere den WebSocket-Server in OBS. - + OBS: %1 OBS: %1 - - The service on that port isn't OBS. + + The service on that port isn't OBS. Der Dienst auf diesem Port ist nicht OBS. PairingPanel - + Check the code Code überprüfen - + Paired Gekoppelt - - Pairing didn't work + + Pairing didn't work Kopplung fehlgeschlagen - + Pair nearby In der Nähe koppeln - - + + Scan with your phone Mit deinem Gerät scannen - - + + Pairing QR code QR-Code zur Kopplung - + Install NectarLink NectarLink installieren - - + + Tap “Pair with PC” Auf „Mit PC koppeln“ tippen - + Scan this code Diesen Code scannen - - + + Code refreshes in %1:%2 · Works on this network Code wird in %1:%2 erneuert · Funktioniert in diesem Netzwerk - - + + Pair nearby instead Stattdessen in der Nähe koppeln - + Open NectarLink on your phone Öffne NectarLink auf deinem Gerät - + Scan this QR code Diesen QR-Code scannen - + On your phone, open NectarLink and choose “Pair nearby”. It shows up here. Öffne NectarLink auf deinem Gerät und wähle „In der Nähe koppeln“. Es erscheint dann hier. - + Pair Koppeln - + Looking for devices… Suche nach Geräten… - + Show the QR code QR-Code anzeigen - + Connecting to %1… %1 wird verbunden… - + Waiting for %1… Warte auf %1… - + Make sure %1 shows the same code. This keeps anyone else from pairing in between. Stelle sicher, dass %1 denselben Code anzeigt. Das verhindert, dass sich jemand anderes dazwischenschaltet. - + your phone dein Gerät - - They don't match + + They don't match Stimmen nicht überein - + Codes match Codes stimmen überein - + %1 is paired with this PC. %1 ist mit diesem PC gekoppelt. - - Pairing was cancelled or the codes didn't match, so nothing was paired. Try again. + + Pairing was cancelled or the codes didn't match, so nothing was paired. Try again. Die Kopplung wurde abgebrochen oder die Codes stimmten nicht überein – es wurde kein Gerät gekoppelt. Versuche es erneut. - + Pairing was declined on one of the devices. Die Kopplung wurde auf einem der Geräte abgelehnt. - - Couldn't reach the phone. Make sure both devices are on the same network. + + Couldn't reach the phone. Make sure both devices are on the same network. Das Gerät konnte nicht erreicht werden. Stelle sicher, dass sich beide Geräte im selben Netzwerk befinden. - + The code expired. Der Code ist abgelaufen. - + Something went wrong. Try again. Etwas ist schiefgelaufen. Versuche es erneut. - + Try again Erneut versuchen - + Cancel Abbrechen @@ -5586,25 +5771,25 @@ Voraussetzungen PcMirror - - Can't find the player's window. Play the video on this PC again, then turn on Play on phone. + + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. Das Player-Fenster wurde nicht gefunden. Spiele das Video erneut auf diesem PC ab und aktiviere dann „Auf dem Gerät abspielen“. PhoneMic - + Tap Start on %1 to use its microphone. Tippe auf %1 auf „Starten“, um das Mikrofon zu verwenden. - + The microphone is turned off for this PC on %1. Das Mikrofon ist auf %1 für diesen PC deaktiviert. - + Update NectarLink on %1 to use its microphone. Aktualisiere NectarLink auf %1, um das Mikrofon zu verwenden. @@ -5612,57 +5797,57 @@ Voraussetzungen PhotoBackup - - Can't write to the backup folder. + + Can't write to the backup folder. In den Sicherungsordner kann nicht geschrieben werden. - + Copying, %1 left Wird kopiert, noch %1 - + Looking for new photos… Suche nach neuen Fotos… - + Off Aus - + Photos are turned off for this PC on %1. Fotos sind auf %1 für diesen PC deaktiviert. - - The backup folder's drive is full. + + The backup folder's drive is full. Das Laufwerk des Sicherungsordners ist voll. - + The backup stopped. It tries again in a few minutes. Die Sicherung wurde unterbrochen. In wenigen Minuten wird es erneut versucht. - + Up to date Auf dem neuesten Stand - + Up to date, %1 backed up Auf dem neuesten Stand, %1 gesichert - + Update NectarLink on %1 to back up its photos. Aktualisiere NectarLink auf %1, um dessen Fotos zu sichern. - + Waiting for %1 Wartet auf %1 @@ -5670,339 +5855,339 @@ Voraussetzungen Photos - + Copied to clipboard. In die Zwischenablage kopiert. - + From %1 Von %1 - + New photo Neues Foto - + New screenshot Neuer Screenshot - + No recent photos found on the phone. Keine aktuellen Fotos auf dem Gerät gefunden. - + Opening latest photo… Neuestes Foto wird geöffnet… - + Saved %1 items to %2. %1 Elemente unter %2 gespeichert. - + Saved %1 to %2. %1 unter %2 gespeichert. - - That photo isn't on the phone anymore. + + That photo isn't on the phone anymore. Dieses Foto befindet sich nicht mehr auf dem Gerät. - - The phone doesn't share photos or files with this PC. + + The phone doesn't share photos or files with this PC. Das Gerät teilt keine Fotos oder Dateien mit diesem PC. - - The phone isn't connected right now. + + The phone isn't connected right now. Das Gerät ist momentan nicht verbunden. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - - The photo couldn't be copied. It's in Downloads\Nectarlink. + + The photo couldn't be copied. It's in Downloads\Nectarlink. Das Foto konnte nicht kopiert werden. Es liegt unter Downloads\Nectarlink. PhotosPage - - + + All photos Alle Fotos - + Album Album - - + + Today Heute - - + + Yesterday Gestern - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Choose default folder for saved photos Standardordner für gespeicherte Fotos auswählen - + Save to folder In Ordner speichern - + Photos Fotos - - - %1 isn't connected + + + %1 isn't connected %1 ist nicht verbunden - + Pair your phone to browse photos and albums. Kopple dein Gerät, um Fotos und Alben zu durchsuchen. - - + + Connect %1 to view photos. Verbinde %1, um Fotos anzuzeigen. - + Albums Alben - + Refresh Aktualisieren - + %1+ items %1+ Elemente - + 1 item 1 Element - + %1 items %1 Elemente - + Cancel Abbrechen - + Photo Foto - + Take photo Foto aufnehmen - + Scan Scannen - + Scan document Dokument scannen - + Select Auswählen - + Cancel selection Auswahl aufheben - + %1 selected %1 ausgewählt - + Select items Elemente auswählen - + Click items to select Klicke auf Elemente, um sie auszuwählen - + Select all Alle auswählen - - + + Copy Kopieren - + Open Öffnen - + Save (%1) Speichern (%1) - - + + Save Speichern - - + + Save to folder… In Ordner speichern… - + Photos are off for %1 Fotos sind für %1 deaktiviert - + Allow photos on %1 Fotos auf %1 erlauben - - Couldn't load photos + + Couldn't load photos Fotos konnten nicht geladen werden - + Enable Photos in Settings. Aktiviere „Fotos“ in den Einstellungen. - + Grant photos permission on %1. Erteile die Foto-Berechtigung auf %1. - + Unable to load photos. Fotos können nicht geladen werden. - + Try again Erneut versuchen - + No photos or videos Keine Fotos oder Videos - + Back to gallery Zurück zur Galerie - - + + Play video Video abspielen - + Open in Photos In Fotos öffnen - + Loading full resolution… Volle Auflösung wird geladen… - + Previous photo Vorheriges Foto - + Next photo Nächstes Foto - + %1 %1 - + Select photo Foto auswählen @@ -6010,32 +6195,32 @@ Voraussetzungen PlayOnPhone - + Connect your phone to play on it. Verbinde dein Gerät, um darauf abzuspielen. - + Media is turned off for %1. Medien sind für %1 deaktiviert. - + Opening in %1 on %2. Tap the notification there. Wird in %1 auf %2 geöffnet. Tippe dort auf die Benachrichtigung. - + Sent to %1. Tap the notification there. An %1 gesendet. Tippe dort auf die Benachrichtigung. - + Tap the notification on %1 to watch. Tippe auf die Benachrichtigung auf %1, um zuzuschauen. - + Update NectarLink on %1 to play on it. Aktualisiere NectarLink auf %1, um darauf abzuspielen. @@ -6043,7 +6228,7 @@ Voraussetzungen Prefs - + Connected to OBS. Mit OBS verbunden. @@ -6051,7 +6236,7 @@ Voraussetzungen Recordings - + Recording from %1 saved Aufnahme von %1 gespeichert @@ -6059,7 +6244,7 @@ Voraussetzungen SadBee - + A sad bee Eine traurige Biene @@ -6067,57 +6252,57 @@ Voraussetzungen SendTo - - Copied to your phone's clipboard. + + Copied to your phone's clipboard. In die Zwischenablage deines Geräts kopiert. - - Couldn't reach %1. + + Couldn't reach %1. %1 konnte nicht erreicht werden. - - Couldn't send %1 + + Couldn't send %1 %1 konnte nicht gesendet werden - - Couldn't send to %1 + + Couldn't send to %1 Senden an %1 fehlgeschlagen - - Couldn't share with %1 + + Couldn't share with %1 Teilen mit %1 fehlgeschlagen - + File failed integrity check; partial file kept to resume. Integritätsprüfung der Datei fehlgeschlagen; Teildatei zum Fortsetzen behalten. - + Send to %1 with NectarLink Mit NectarLink an %1 senden - + Sending to %1… Wird an %1 gesendet… - + Sent to %1 An %1 gesendet - - The transfer didn't go through. + + The transfer didn't go through. Die Übertragung konnte nicht abgeschlossen werden. - + Waiting for %1… Wartet auf %1… @@ -6125,1448 +6310,1495 @@ Voraussetzungen SettingsPage - + Appearance Darstellung - + Theme, mode & colors Design, Modus & Farben - + General Allgemein - + Startup, clipboard & desktop Autostart, Zwischenablage & Desktop - + Devices Geräte - + Paired phones & permissions Gekoppelte Geräte & Berechtigungen - + Camera & Audio Kamera & Audio - + Webcam, speaker & calls Webcam, Lautsprecher & Anrufe - - + + Notifications Benachrichtigungen - + Do not disturb & app rules Nicht stören & App-Regeln - + Files & Sync Dateien & Synchronisierung - + Folder sync, storage & recordings Ordnersynchronisierung, Speicher & Aufnahmen - + Shortcuts & Rules Tastenkürzel & Regeln - + Hotkeys & automations Hotkeys & Automatisierungen - + Storage & Privacy Speicher & Datenschutz - + History, caches & retention Verlauf, Caches & Aufbewahrung - + Connection & About Verbindung & Info - + Doctor, Wake-on-LAN, API & version Verbindungs-Doktor, Wake-on-LAN, API & Version - + When phone connects Wenn Gerät verbunden wird - + When phone disconnects Wenn Gerät getrennt wird - + When phone rings Wenn Gerät klingelt - + When call ends Wenn Anruf endet - + When phone battery is low Wenn Geräteakku schwach ist - + When phone is charged Wenn Gerät geladen ist - + Lock this PC Diesen PC sperren - + Sleep this PC Diesen PC in den Energiesparmodus versetzen - + Pause PC media PC-Medien pausieren - + Resume PC media PC-Medien fortsetzen - + Mute PC audio PC-Audio stummschalten - + Unmute PC audio Stummschaltung für PC-Audio aufheben - + Run command Befehl ausführen - + Show notification Benachrichtigung anzeigen - - + + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Messages Nachrichten - - + + Calls Anrufe - + Contacts Kontakte - + Clipboard Zwischenablage - + Files Dateien - - + + Voice recordings Sprachaufnahmen - + Media controls Mediensteuerung - + Photos Fotos - + Phone controls Gerätesteuerung - + Lock, sleep & wake PC PC sperren, in den Energiesparmodus versetzen & aufwecken - + Screen mirroring Bildschirmspiegelung - + Share PC screen to phone PC-Bildschirm für Gerät freigeben - + Webcam Webcam - + File Explorer integration Integration in den Datei-Explorer - + + File manager integration + Integration in den Dateimanager + + + Remote mouse, keyboard & Deck Remote-Maus, -Tastatur & Deck - + Run Deck shell commands Deck-Shell-Befehle ausführen - + Remote PC file access Remote-Dateizugriff auf den PC - - Allow this phone to view this PC's screen. + + Allow this phone to view this PC's screen. Diesem Gerät erlauben, den Bildschirm dieses PCs zu sehen. - + Only enable for a phone you trust. Nur für ein Gerät aktivieren, dem du vertraust. - + Calls, messages & notifications Anrufe, Nachrichten & Benachrichtigungen - + Files, photos & clipboard Dateien, Fotos & Zwischenablage - + Screen, camera & control Bildschirm, Kamera & Steuerung - + Back up photos to Fotos sichern unter - + Save recordings in Aufnahmen speichern unter - + Pair a new device Neues Gerät koppeln - + Theme Design - + Hive Wabe - + Bloom Blüte - + Glass Glass - + Graphite Graphit - + Mode Modus - - + + System System - + Light Hell - + Dark Dunkel - + Color Farbe - + Wallpaper Hintergrundbild - + AMOLED AMOLED - - - + + + Pure black (AMOLED) Reines Schwarz (AMOLED) - - + + Media artwork Albumcover - - + + Animated comb Animierte Wabe - + + Still while animations are off in system settings. + Statisch, solange Animationen in den Systemeinstellungen deaktiviert sind. + + + Still while animations are off in Windows. Statisch, solange Animationen in Windows deaktiviert sind. - + Still while energy saver is on. Statisch, solange der Energiesparmodus aktiv ist. - + The honeycomb breathes and pulses. Die Bienenwabe atmet und pulsiert. - + Pitch-black #000000 surfaces. Tiefschwarze #000000-Oberflächen. - - + + Mica backdrop Mica-Hintergrund - + Tint window with desktop wallpaper. Fenster mit dem Desktop-Hintergrund tönen. - - + + Language Sprache - + + NectarLink follows the system unless you pick one. + NectarLink übernimmt die Systemsprache, sofern du keine auswählst. + + + NectarLink follows Windows unless you pick one. NectarLink übernimmt die Windows-Sprache, sofern du keine auswählst. - + More Mehr - - + + Start with Windows Mit Windows starten - + + + Start at login + Bei der Anmeldung starten + + + Launch in the tray when you sign in. Beim Anmelden im Infobereich starten. - - + + Stay in system tray Im Infobereich bleiben - + Keep running when the window is closed. Beim Schließen des Fensters im Hintergrund weiterlaufen. - - + + Nectar Island Nectar Island - + Top-screen pill for calls, transfers & media. Leiste am oberen Bildschirmrand für Anrufe, Übertragungen & Medien. - - + + Battery alerts Akku-Warnungen - + Notify when phone battery is low or full. Benachrichtigen, wenn der Geräteakku fast leer oder voll geladen ist. - + Island triggers Island-Auslöser - + Choose when the top pill appears. Wähle aus, wann die obere Leiste eingeblendet wird. - + Transfers Übertragungen - + Media Medien - + Alerts Hinweise - - + + Sync clipboard to phone Zwischenablage mit Gerät synchronisieren - + Share copied text and images with your phone. Kopierten Text und Bilder mit deinem Gerät teilen. - - + + Clipboard follows control Zwischenablage folgt Steuerung - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. Zwischenablage an das Gerät senden, sobald du es steuerst oder den Mauszeiger in sein Fenster bewegst. - - + + Copy one-time codes Einmalcodes kopieren - + Auto-copy verification codes from texts. Bestätigungscodes aus SMS automatisch kopieren. - - + + Copy received files Empfangene Dateien kopieren - + Ready to Ctrl+V incoming files anywhere. Eingehende Dateien überall direkt mit Strg+V einfügen. - - + + Suggest clipboard actions Aktionen für die Zwischenablage vorschlagen - + Open links, maps, numbers & emails from phone clips. Links, Karten, Telefonnummern & E-Mails aus der Geräte-Zwischenablage öffnen. - + No phones paired yet Noch kein Gerät gekoppelt - + Pair your phone to get started. Kopple dein Gerät, um loszulegen. - + Pair a phone Gerät koppeln - + Connected Verbunden - + Not connected Nicht verbunden - - + + Unpair Entkoppeln - - Join this phone's hotspot + + Join this phone's hotspot Mit dem Hotspot dieses Geräts verbinden - + When Instant Hotspot turns it on, this PC joins %1. Wenn Instant Hotspot ihn aktiviert, verbindet sich dieser PC mit %1. - - Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + Verbinde dich einmal über die WLAN-Liste deines Systems mit dem Hotspot des Geräts und wähle ihn dann hier aus. + + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. Verbinde dich einmal über die WLAN-Liste von Windows mit dem Hotspot des Geräts und wähle ihn dann hier aus. - + Choose network Netzwerk auswählen - - - - - - + + + + + + Open Öffnen - - + + Stream PC audio to phone PC-Audio auf Gerät streamen - + Listen to PC audio through your phone. Höre den PC-Ton über dein Gerät. - - + + Pause media during calls Medien während Anrufen pausieren - + Auto-pause PC media when phone rings. PC-Medien automatisch pausieren, wenn das Gerät klingelt. - - + + Mute this PC while a phone watches Diesen PC stummschalten, während ein Gerät zuschaut - + With Play on phone, the phone still gets the sound. Bei „Auf dem Gerät abspielen“ wird der Ton weiterhin auf dem Gerät wiedergegeben. - + Phone as microphone Gerät als Mikrofon - + Live from %1, playing into %2. Live von %1, Wiedergabe über %2. - + Tap Start on %1. Tippe auf %1 auf „Starten“. - + Plays into %1, so apps can pick its other end as a microphone. Wird über %1 ausgegeben, sodass Apps die Gegenseite als Mikrofon auswählen können. - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. Wird über %1 ausgegeben. Um es in anderen Apps als Mikrofon zu nutzen, installiere ein virtuelles Audiokabel wie VB-CABLE. - + Output Ausgabe - + Start Starten - + Stop Stoppen - - + + Microphone volume Mikrofonlautstärke - - + + %1% %1% - + Virtual camera add-on Add-on für virtuelle Kamera - + Registered for Zoom, Teams & Meet. Registriert für Zoom, Teams & Meet. - + One-time setup (admin required) for Zoom, Teams & Meet. Einmalige Einrichtung (Administratorrechte erforderlich) für Zoom, Teams & Meet. - - + + Ready Bereit - - - + + + Remove Entfernen - + Set up Einrichten - + Phone Gerät - + Camera Kamera - + Back Hinten - + Front Vorne - - + + Mirror image Bild spiegeln - + Resolution Auflösung - + Preview · %1 Vorschau · %1 - + Pair a phone to use its camera as a webcam Kopple ein Gerät, um dessen Kamera als Webcam zu nutzen - + Stop webcam Webcam stoppen - + Start webcam Webcam starten - - + + Follow phone Do not disturb „Nicht stören“ des Geräts übernehmen - + Mute pop-ups while phone is on DND. Pop-ups stummschalten, solange auf dem Gerät „Nicht stören“ aktiv ist. - - + + Forward PC notifications PC-Benachrichtigungen weiterleiten - + Send PC alerts to phone while away. PC-Hinweise bei Abwesenheit an das Gerät senden. - + Quiet hours Ruhezeiten - + Configure automatic DND schedule in Windows. Automatischen Zeitplan für „Nicht stören“ in Windows einrichten. - + Set a schedule Zeitplan festlegen - + Show Anzeigen - + No pop-ups Keine Pop-ups - + Hide Ausblenden - - + + File Explorer Send to „Senden an“ im Datei-Explorer - + + + File manager Send to + „Senden an“ im Dateimanager + + + Send files from right-click menu. Dateien über das Rechtsklick-Menü senden. - - + + Your PC in your pocket Dein PC in deiner Tasche - + Share Downloads, Documents, Desktop & Pictures. Downloads, Dokumente, Desktop & Bilder freigeben. - - + + LocalSend on local network LocalSend im lokalen Netzwerk - + Share with nearby LocalSend devices without pairing. Ohne Kopplung mit LocalSend-Geräten in der Nähe teilen. - - + + Open files shared from the phone Vom Gerät geteilte Dateien öffnen - + Opens a shared photo or document as soon as it arrives. Öffnet ein geteiltes Foto oder Dokument automatisch nach dem Empfang. - - - - - + + + + + Reset Zurücksetzen - - + + Choose folder Ordner auswählen - + Photo backup Fotosicherung - - Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. + + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. Kopiert neue Fotos aus jedem Album nach Jahr und Monat sortiert in einen Ordner auf diesem PC. Weder hier noch auf dem Gerät wird etwas gelöscht. Benötigt nur die Foto-Berechtigung auf dem Gerät. - + Back up photos from %1 Fotos von %1 sichern - + Backup folder Sicherungsordner - - + + Include videos Videos einschließen - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. Videos können groß sein. Sie werden einzeln kopiert, sodass bei einem Verbindungsabbruch höchstens eine Datei betroffen ist. - + Back up now Jetzt sichern - + Synced folders Synchronisierte Ordner - + One-way or two-way folder sync between phone and PC. Einseitige oder zweiseitige Ordnersynchronisierung zwischen Gerät und PC. - + Sync now Jetzt synchronisieren - + Add DCIM → Pictures DCIM → Bilder hinzufügen - + Two-way sync · conflict copies kept Zwei-Wege-Synchronisierung · Konfliktkopien werden behalten - + One-way · phone to PC Einseitig · Gerät zu PC - + Command palette Befehlspalette - + Open from any app. Aus jeder App heraus öffnen. - + Command palette shortcut Tastenkürzel für Befehlspalette - + File shelf Datei-Shelf - + Toggle screen-edge shelf. Shelf am Bildschirmrand ein-/ausblenden. - + Shelf shortcut Tastenkürzel für Shelf - + Take photo Foto aufnehmen - + Capture & paste photo. Foto aufnehmen & einfügen. - + Take photo shortcut Tastenkürzel für Fotoaufnahme - + Scan document Dokument scannen - + Scan & paste document. Dokument scannen & einfügen. - + Scan document shortcut Tastenkürzel für Dokumentenscan - + Trigger-action rules Auslöser-Aktions-Regeln - + Run actions when your phone connects, disconnects, or rings. Aktionen ausführen, wenn dein Gerät verbunden oder getrennt wird oder klingelt. - + Lock PC on disconnect PC beim Trennen sperren - + Pause media on ring Medien beim Klingeln pausieren - + OBS Studio OBS Studio - - For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. + + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. Für Deck-Kacheln, die obs:-Befehle ausführen. Aktiviere den WebSocket-Server in OBS unter Werkzeuge › WebSocket-Server-Einstellungen. - + Port Port - + OBS WebSocket port OBS-WebSocket-Port - + Password Passwort - + OBS WebSocket password OBS-WebSocket-Passwort - + Saved Gespeichert - + None Keines - + Save Speichern - + Test connection Verbindung testen - + Remove password Passwort entfernen - - + + Clipboard history Zwischenablageverlauf - + %1 clips · %2 · kept 7 days %1 Einträge · %2 · 7 Tage aufbewahrt - - - - - - + + + + + + Clear Leeren - - + + Notification history Benachrichtigungsverlauf - + %1 alerts · %2 · kept 24h %1 Hinweise · %2 · 24 Std. aufbewahrt - + Cross-device timeline Geräteübergreifender Zeitverlauf - + 1 item stored (max 5,000). 1 Eintrag gespeichert (max. 5.000). - + %1 items stored (max 5,000). %1 Einträge gespeichert (max. 5.000). - + 30d 30 T. - + 90d 90 T. - + 1y 1 J. - + All Alle - + Cached messages Zwischengespeicherte Nachrichten - + 1 chat 1 Chat - + %1 chats %1 Chats - + 1 message 1 Nachricht - + %1 messages %1 Nachrichten - + %1 · %2 · %3 · kept 90 days %1 · %2 · %3 · 90 Tage aufbewahrt - + Photo thumbnails Foto-Vorschaubilder - + %1 cached · %2 · kept 30 days %1 im Cache · %2 · 30 Tage aufbewahrt - + Transfer history Übertragungsverlauf - + %1 records · kept with timeline %1 Einträge · mit Zeitverlauf aufbewahrt - + Clear all local history & caches Gesamten lokalen Verlauf & Caches löschen - + Wipes local history and caches; keeps paired devices and settings. Löscht den lokalen Verlauf und die Caches; gekoppelte Geräte und Einstellungen bleiben erhalten. - + Clear everything… Alles löschen… - + Connection Doctor Verbindungs-Doktor - + Diagnose and fix firewall, network, or VPN issues. Firewall-, Netzwerk- oder VPN-Probleme diagnostizieren und beheben. - + Check Prüfen - + Connect over mobile data Über mobile Daten verbinden - + Stay connected over LTE/5G or relay when away from Wi-Fi. Über LTE/5G oder Relay verbunden bleiben, wenn kein WLAN verfügbar ist. - + Wake from your phone Vom Gerät aus aufwecken - + No active network adapter found. Kein aktiver Netzwerkadapter gefunden. - + %1 · Wake on Magic Packet ready. %1 · Wake on Magic Packet bereit. - + %1 · Enable Wake on Magic Packet in Device Manager. %1 · Aktiviere „Wake on Magic Packet“ im Geräte-Manager. - + Off in Windows In Windows deaktiviert - + + Off in the adapter's settings + In den Adaptereinstellungen deaktiviert + + + Check settings Einstellungen prüfen - + No adapter Kein Adapter - - + + Local automation API Lokale Automatisierungs-API - + Listening on 127.0.0.1:%1 · Token in %2 Lauscht auf 127.0.0.1:%1 · Token in %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. Lokaler HTTP/SSE-Server (127.0.0.1:%1) für Skripte und Tools. - + NectarLink %1 NectarLink %1 - + This PC: %1 Dieser PC: %1 - + Update download Update-Download - + Update to %1 Auf %1 aktualisieren - - - + + + Check for updates Nach Updates suchen - + Open logs Protokolle öffnen - + Looks for a new version on GitHub once a day. Sucht einmal täglich auf GitHub nach einer neuen Version. - - + + Install updates automatically Updates automatisch installieren - + When nobody is using the PC and nothing is in progress. Wenn der PC gerade nicht genutzt wird und keine Vorgänge laufen. - + Setup walkthrough Einrichtungsassistent - + Re-open the setup guide. Einrichtungsassistenten erneut öffnen. - + Features guide Funktionsübersicht - + What each feature needs and where to find it. Was jede Funktion benötigt und wo du sie findest. - - Phone's hotspot network + + Phone's hotspot network Hotspot-Netzwerk des Geräts - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + WLAN-Netzwerke, mit denen dieser PC bereits verbunden war. Dein System speichert deren Passwörter; NectarLink sieht sie nie. + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + Dieser PC hat noch keine WLAN-Netzwerke gespeichert. Verbinde dich zuerst einmal über die WLAN-Liste deines Systems mit dem Hotspot des Geräts. + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. WLAN-Netzwerke, mit denen dieser PC bereits verbunden war. Windows speichert deren Passwörter; NectarLink sieht sie nie. - - This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. Dieser PC hat noch keine WLAN-Netzwerke gespeichert. Verbinde dich zuerst einmal über die WLAN-Liste von Windows mit dem Hotspot des Geräts. - - Don't join + + Don't join Nicht verbinden - - - + + + Close Schließen - - Play the phone's microphone into + + Play the phone's microphone into Gerätemikrofon ausgeben über - + Unpair %1? %1 entkoppeln? - + Both devices forget each other. You can pair them again any time. Beide Geräte vergessen einander. Du kannst sie jederzeit erneut koppeln. - - + + Cancel Abbrechen - + Clear all local history & caches? Gesamten lokalen Verlauf & Caches löschen? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. Löscht Zwischenablageverlauf, Zeitverlauf, Benachrichtigungsverlauf, zwischengespeicherte Nachrichten, Foto-Vorschaubilder und Übertragungsverlauf auf diesem PC. Gekoppelte Geräte, Einstellungen und heruntergeladene Dateien bleiben erhalten. - + Clear everything Alles löschen @@ -7574,209 +7806,219 @@ Voraussetzungen Setup - - Couldn't start setup: %1 + + Couldn't start setup: %1 Installation konnte nicht gestartet werden: %1 - + + Couldn't uninstall NectarLink: %1 + NectarLink konnte nicht deinstalliert werden: %1 + + + Setup is taking too long. Try again after restarting the PC. Die Installation dauert zu lange. Starte den PC neu und versuche es erneut. - + Setup stopped with code %1. Installation mit Code %1 abgebrochen. - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. Das Deinstallationsprogramm fehlt. Installiere NectarLink erneut und deinstalliere es anschließend. + + + Uninstall stopped with code %1. + Die Deinstallation wurde mit Code %1 beendet. + SetupWindow - + Uninstall NectarLink NectarLink deinstallieren - + NectarLink Setup NectarLink-Installation - - - - + + + + Close Schließen - + NectarLink NectarLink - - Couldn't uninstall NectarLink + + Couldn't uninstall NectarLink NectarLink konnte nicht deinstalliert werden - - Setup couldn't finish + + Setup couldn't finish Installation konnte nicht abgeschlossen werden - + Uninstalling NectarLink NectarLink wird deinstalliert - + Updating NectarLink NectarLink wird aktualisiert - + Installing NectarLink NectarLink wird installiert - + Uninstall NectarLink? NectarLink deinstallieren? - + Sorry to see you go. This uninstalls NectarLink and deletes its settings and pairings on this PC. Files you received stay in %1. Schade, dass du gehst. Dadurch wird NectarLink deinstalliert und alle Einstellungen sowie Kopplungen auf diesem PC werden gelöscht. Empfangene Dateien bleiben in %1 erhalten. - + Update from %1 to %2. Your settings and pairings stay as they are. Aktualisierung von %1 auf %2. Deine Einstellungen und Kopplungen bleiben erhalten. - + Version %1 is already installed. Install it again to repair it. Version %1 ist bereits installiert. Installiere sie erneut, um sie zu reparieren. - + This PC has a newer version (%1). Installing %2 replaces it. Auf diesem PC ist eine neuere Version (%1) installiert. Durch die Installation von %2 wird sie ersetzt. - + Your Android phone and your PC, working as one. Dein Android-Gerät und dein PC – perfekt verbunden. - + Try again Erneut versuchen - + Uninstall Deinstallieren - + Update Aktualisieren - + Reinstall Erneut installieren - + Install Installieren - + Keep NectarLink NectarLink behalten - - + + What this installs Was installiert wird - + NectarLink is uninstalled NectarLink wurde deinstalliert - + NectarLink is ready NectarLink ist bereit - + Your received files are still in %1. Thanks for giving it a try. Deine empfangenen Dateien befinden sich weiterhin in %1. Danke fürs Ausprobieren! - - Next, get the Android app. Scan this code with your phone's camera to open its download page. + + Next, get the Android app. Scan this code with your phone's camera to open its download page. Hole dir als Nächstes die Android-App. Scanne diesen Code mit der Kamera deines Geräts, um die Download-Seite zu öffnen. - + QR code for the Android app download page QR-Code für die Download-Seite der Android-App - + Open the Android app download page Download-Seite der Android-App öffnen - + Open NectarLink NectarLink öffnen - + NectarLink goes into Program Files for everyone on this PC. Setup also: NectarLink wird unter „Programme“ für alle Benutzer dieses PCs installiert. Außerdem führt die Installation Folgendes aus: - + allows it through Windows Firewall on private networks, so your phone can connect lässt NectarLink in privaten Netzwerken durch die Windows-Firewall zu, damit sich dein Gerät verbinden kann - + adds a task that installs updates without an administrator prompt richtet eine Aufgabe ein, die Updates ohne Administratorabfrage installiert - + adds Start menu and desktop shortcuts, and starts it with Windows (turn this off in Settings) erstellt Verknüpfungen im Startmenü und auf dem Desktop und startet die App mit Windows (in den Einstellungen deaktivierbar) - + adds it to the Windows Share sheet fügt NectarLink zum Teilen-Menü von Windows hinzu - + It checks GitHub for updates once a day, which you can turn off in Settings. Otherwise your phone and PC only talk to each other. Es sucht einmal täglich auf GitHub nach Updates, was du in den Einstellungen ausschalten kannst. Ansonsten kommunizieren dein Gerät und dein PC ausschließlich direkt miteinander. - + Privacy details Details zum Datenschutz @@ -7784,174 +8026,174 @@ Voraussetzungen ShelfWindow - + NectarLink Shelf NectarLink Shelf - + phone Gerät - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + Shelf Shelf - + No phone connected Kein Gerät verbunden - + Refresh Shelf Shelf aktualisieren - + Close Shelf Shelf schließen - + Release to send to %1 Loslassen, um an %1 zu senden - + Sent to %1 An %1 gesendet - + Send cancelled Senden abgebrochen - + Drop or flick items here to send Elemente zum Senden hier ablegen oder nach oben wischen - + Sending to %1… Wird an %1 gesendet… - + Undo Rückgängig - + Latest photo Neuestes Foto - + No recent photo Kein aktuelles Foto - + Latest screenshot Neuester Screenshot - + No recent screenshot Kein aktueller Screenshot - + Copy Kopieren - + Open Öffnen - + Current clip Aktuelle Zwischenablage - + Send clip to phone Zwischenablage an Gerät senden - - + + Clip Zwischenablage - + Copy clip Inhalt kopieren - - + + Clipboard is empty Zwischenablage ist leer - + Current clip: %1 Aktuelle Zwischenablage: %1 - + Recent received files Zuletzt empfangene Dateien - + No received files yet Noch keine empfangenen Dateien - + Send to phone An Gerät senden - + Open file Datei öffnen - + Show in folder Im Ordner anzeigen - + Flick any card up to send to phone, drag out to use, or drop files in Wische eine Karte nach oben, um sie ans Gerät zu senden, ziehe sie heraus oder lege Dateien hier ab @@ -7959,147 +8201,147 @@ Voraussetzungen Timeline - + App window #%1 App-Fenster #%1 - + Clip (removed from clipboard history) Eintrag (aus dem Zwischenablageverlauf entfernt) - + Copied image Kopiertes Bild - + Copied link to clipboard. Link in die Zwischenablage kopiert. - + Copied to clipboard. In die Zwischenablage kopiert. - + From %1 Von %1 - + In progress In Bearbeitung - + No phone is paired yet. Noch kein Gerät gekoppelt. - + Opened from %1 Von %1 geöffnet - + Opened on %1 Auf %1 geöffnet - + Phone microphone Gerätemikrofon - + Phone webcam Geräte-Webcam - + Recorded on %1 Auf %1 aufgenommen - + Saved from %1 Von %1 gespeichert - + Screen mirroring Bildschirmspiegelung - + Sending to %1… Wird an %1 gesendet… - + Sent clip to %1. Zwischenablage an %1 gesendet. - + Sent to %1 An %1 gesendet - + Session Sitzung - + That clip is no longer in clipboard history. Dieser Eintrag befindet sich nicht mehr im Zwischenablageverlauf. - - That entry couldn't be removed. Try again. + + That entry couldn't be removed. Try again. Dieser Eintrag konnte nicht entfernt werden. Versuche es erneut. - + That file was moved or deleted. Diese Datei wurde verschoben oder gelöscht. - - That isn't a web link. + + That isn't a web link. Das ist kein Weblink. - + That photo was moved or deleted. Dieses Foto wurde verschoben oder gelöscht. - - The Timeline couldn't be cleared. Try again. + + The Timeline couldn't be cleared. Try again. Der Zeitverlauf konnte nicht geleert werden. Versuche es erneut. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - + Voice recording Sprachaufnahme - + With %1 Mit %1 - + device Gerät @@ -8107,145 +8349,145 @@ Voraussetzungen TimelinePage - + All Alle - + Files Dateien - + Clips Zwischenablage - + Links Links - + Photos Fotos - + Recordings Aufnahmen - + Sessions Sitzungen - - - - + + + + All devices Alle Geräte - + Today Heute - + Yesterday Gestern - + Search timeline Zeitverlauf durchsuchen - + Search timeline… Zeitverlauf durchsuchen… - + Clear search Suche löschen - + Clear all Alle löschen - + Device filter: %1 Gerätefilter: %1 - + Clear timeline history? Saved files on disk are kept. Zeitverlauf löschen? Auf der Festplatte gespeicherte Dateien bleiben erhalten. - + Cancel Abbrechen - + Clear timeline Zeitverlauf löschen - + No matching items Keine passenden Einträge - + No recent activity Keine kürzlichen Aktivitäten - + Clear the search or filter. Setze die Suche oder den Filter zurück. - + Shared files, clips, links, and photos appear here. Geteilte Dateien, Zwischenablage-Einträge, Links und Fotos erscheinen hier. - + Open Öffnen - + Show in folder Im Ordner anzeigen - + Copy Kopieren - + Send again Erneut senden - + Remove from timeline Aus Zeitverlauf entfernen - + Load more Mehr laden @@ -8253,137 +8495,137 @@ Voraussetzungen TransferItem - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + %1 wants to send this · %2 %1 möchte Folgendes senden · %2 - + Nearby device Gerät in der Nähe - + Paused; continues when %1 is back Pausiert; wird fortgesetzt, sobald %1 wieder da ist - + Waiting for %1 Wartet auf %1 - + %1 of %2 · %3/s %1 von %2 · %3/s - + %1 of %2 %1 von %2 - + Paused · %1 of %2 Pausiert · %1 von %2 - + From %1 · %2 Von %1 · %2 - + Sent to %1 · %2 An %1 gesendet · %2 - + Cancelled Abgebrochen - - %1 doesn't accept files from this PC + + %1 doesn't accept files from this PC %1 nimmt keine Dateien von diesem PC an - - Couldn't reach %1 + + Couldn't reach %1 %1 konnte nicht erreicht werden - + Not enough space on %1 Nicht genügend Speicherplatz auf %1 - + Integrity check failed · partial file kept to resume Integritätsprüfung fehlgeschlagen · Teildatei zum Fortsetzen behalten - - Didn't go through + + Didn't go through Fehlgeschlagen - + Accept Annehmen - + Decline Ablehnen - + Open Öffnen - + Show in folder Im Ordner anzeigen - + Pause Pause - + Resume Fortsetzen - + Cancel Abbrechen @@ -8391,105 +8633,105 @@ Voraussetzungen Transfers - + Accept in NectarLink to save to Downloads\Nectarlink In NectarLink annehmen, um unter Downloads\Nectarlink zu speichern - + Files are turned off for that device. Dateien sind für dieses Gerät deaktiviert. - + From %1, opened from Downloads\Nectarlink Von %1, geöffnet aus Downloads\Nectarlink - + From %1, saved in Downloads\Nectarlink Von %1, gespeichert unter Downloads\Nectarlink - + Nearby device Gerät in der Nähe - - That's too many files to send at once. + + That's too many files to send at once. Das sind zu viele Dateien auf einmal. - - There's nothing to send in there. + + There's nothing to send in there. Dort gibt es nichts zu senden. Updater - - Couldn't check for updates. Check your internet connection. + + Couldn't check for updates. Check your internet connection. Suche nach Updates fehlgeschlagen. Prüfe deine Internetverbindung. - + NectarLink %1 is available NectarLink %1 ist verfügbar - + NectarLink %1 is available. NectarLink %1 ist verfügbar. - + NectarLink is still on the version you had. Try again from Settings. NectarLink befindet sich noch auf der bisherigen Version. Versuche es über die Einstellungen erneut. - + NectarLink is up to date. NectarLink ist auf dem neuesten Stand. - + NectarLink is updated to %1 NectarLink wurde auf %1 aktualisiert - - The download was damaged, so it wasn't installed. Try again. + + The download was damaged, so it wasn't installed. Try again. Der Download war beschädigt und wurde daher nicht installiert. Versuche es erneut. - - The update couldn't be downloaded. Check your internet connection and try again. + + The update couldn't be downloaded. Check your internet connection and try again. Das Update konnte nicht heruntergeladen werden. Prüfe deine Internetverbindung und versuche es erneut. - - The update couldn't be saved. Check that there's free space and try again. + + The update couldn't be saved. Check that there's free space and try again. Das Update konnte nicht gespeichert werden. Prüfe, ob genügend freier Speicherplatz vorhanden ist, und versuche es erneut. - - The update couldn't be started. Try again. + + The update couldn't be started. Try again. Das Update konnte nicht gestartet werden. Versuche es erneut. - - The update didn't install + + The update didn't install Das Update wurde nicht installiert - + Update now, or later from Settings. Jetzt oder später über die Einstellungen aktualisieren. - + Your phone reconnects on its own. Dein Gerät verbindet sich automatisch wieder. @@ -8497,191 +8739,202 @@ Voraussetzungen WalkthroughPage - + Skip setup Einrichtung überspringen - + Notifications and Calls Benachrichtigungen und Anrufe - + Clipboard and Send files Zwischenablage und Dateien senden - + Mirroring, Camera, and Audio Bildschirmspiegelung, Kamera und Audio - + Direct local QUIC link Direkte lokale QUIC-Verbindung - + End-to-end encrypted over QUIC Ende-zu-Ende-verschlüsselt über QUIC - + Hardware-backed device keys Hardwaregestützte Geräteschlüssel - + Local network by default Standardmäßig im lokalen Netzwerk - + Option 1: Download the Android APK (Android 8.0+) Option 1: Android-APK herunterladen (Android 8.0+) - + Open the latest GitHub Release on your phone or copy the release link. Öffne das neueste GitHub-Release auf deinem Gerät oder kopiere den Release-Link. - + Open release page Release-Seite öffnen - + Option 2: Install over USB with ADB Option 2: Über USB mit ADB installieren - + Paired with %1. You can continue to the next step. Kopplung für %1 abgeschlossen. Du kannst mit dem nächsten Schritt fortfahren. - - + + your phone dein Gerät - - + + On by default Standardmäßig aktiviert - - + + Off until you turn them on Deaktiviert, bis du sie einschaltest - + Change these per phone on Home after pairing. Du kannst diese nach dem Koppeln pro Gerät auf der Startseite anpassen. - + Sharing settings for %1 Freigabeeinstellungen für %1 - + Notifications Benachrichtigungen - + Clipboard Zwischenablage - + Send files Dateien senden - + Now playing Aktuelle Wiedergabe - + Messages Nachrichten - + Calls Anrufe - + Screen mirroring Bildschirmspiegelung - + Remote input Remote-Eingabe - + Share PC folders PC-Ordner freigeben - + Windows notifications Windows-Benachrichtigungen - + + Desktop notifications + Desktop-Benachrichtigungen + + + Windows Settings Windows-Einstellungen - + Recheck Erneut prüfen - - + + Start with Windows Mit Windows starten - + + + Start at login + Bei der Anmeldung starten + + + Keep NectarLink ready in the system tray when you sign in. NectarLink beim Anmelden im Infobereich bereithalten. - + Firewall and network check Firewall- und Netzwerkprüfung - + Command Palette (Ctrl+K) Befehlspalette (Strg+K) - + Features guide Funktionsübersicht - + Back Zurück - + Open features guide Funktionsübersicht öffnen @@ -8689,12 +8942,12 @@ Voraussetzungen Webcam - + Starting camera on %1… Kamera auf %1 wird gestartet… - + Streaming %1 × %2 · %3 fps from %4 Streamt %1 × %2 · %3 FPS von %4 @@ -8702,22 +8955,22 @@ Voraussetzungen WelcomePage - + Welcome to NectarLink Willkommen bei NectarLink - - NectarLink couldn't start. + + NectarLink couldn't start. NectarLink konnte nicht gestartet werden. - + Your phone and this PC, together. Pair once and they find each other from then on. Dein Gerät und dieser PC – vereint. Einmal koppeln, und sie finden sich von da an automatisch. - + Open the logs folder Protokollordner öffnen @@ -8725,17 +8978,17 @@ Voraussetzungen Widget - + No phone connected Kein Gerät verbunden - + No recent clipboard items Keine aktuellen Einträge in der Zwischenablage - + Not available Nicht verfügbar diff --git a/desktop/app/i18n/nectarlink_zh_CN.ts b/desktop/app/i18n/nectarlink_zh_CN.ts index 5b130adc..4f7559fd 100644 --- a/desktop/app/i18n/nectarlink_zh_CN.ts +++ b/desktop/app/i18n/nectarlink_zh_CN.ts @@ -177,57 +177,62 @@ 配对手机后,即可在本机的窗口中打开其应用。 - + + Opening apps in windows isn't supported on this phone. + 此手机不支持在窗口中打开应用。 + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. 在手机上的 NectarLink 中设置无线调试,即可在此打开其应用。 - + Search apps 搜索应用 - + Search %1's apps 搜索 %1 的应用 - + Refresh 刷新 - + No apps match. 没有匹配的应用。 - + No apps to open. 没有可打开的应用。 - + Getting the phone's apps… 正在获取手机应用… - + Try again 重试 - + Recent 最近 - + All apps 全部应用 - + %1, open %1,打开 @@ -430,277 +435,277 @@ - + Yesterday 昨天 - + %1s %1秒 - + Call from your PC 从电脑拨打电话 - - - + + + %1 isn't connected %1未连接 - + Pair your phone to view calls and contacts. 配对手机以查看通话和联系人。 - + Connect %1 to view calls and contacts. 连接%1以查看通话和联系人。 - + Recent 最近 - - + + Contacts 联系人 - + Refresh 刷新 - - + + Search calls 搜索通话 - - + + Search contacts 搜索联系人 - + Clear search 清除搜索 - + Calls are off for %1 %1的通话功能已关闭 - + Allow call log on %1 在%1上允许通话记录 - + Couldn't load recent calls 无法加载最近通话 - + Enable Calls in Settings. 请在设置中启用通话。 - + Grant call log permission on %1. 请在%1上授予通话记录权限。 - + Connect %1 to view recent calls. 连接%1以查看最近通话。 - + Unable to load call log. 无法加载通话记录。 - - + + Try again 重试 - + No calls match. 没有匹配的通话。 - + No recent calls. 暂无最近通话。 - + Contacts are off for %1 %1的联系人功能已关闭 - + Allow contacts on %1 在%1上允许联系人 - + Couldn't load contacts 无法加载联系人 - + Enable Contacts in Settings. 请在设置中启用联系人。 - + Grant contacts permission on %1. 请在%1上授予联系人权限。 - + Connect %1 to view contacts. 连接%1以查看联系人。 - + Unable to load contacts. 无法加载联系人。 - + No contacts match. 没有匹配的联系人。 - + No contacts on this phone. 此手机上没有联系人。 - + Close contact 关闭联系人 - + Copy number 复制号码 - - - + + + Text %1 给%1发短信 - - - + + + Call %1 呼叫%1 - + Phone number to call 要拨打的电话号码 - + Enter a number 输入号码 - + Backspace 退格 - + Text 短信 - + Calling… 正在呼叫… - + Call 呼叫 - + Clear number 清除号码 - + PC call audio connected 电脑通话音频已连接 - + PC call audio ready 电脑通话音频已就绪 - + PC call audio (Bluetooth) 电脑通话音频(蓝牙) - + Connect PC audio 连接电脑音频 - + Open Bluetooth settings 打开蓝牙设置 - + Missed 未接 - + Outgoing 呼出 - + Declined 已拒接 - + Incoming 来电 - + Favorites 收藏 - + +%1 more 另有%1项 @@ -708,22 +713,22 @@ CaptionButtons - + Minimize 最小化 - + Restore 还原 - + Maximize 最大化 - + Close 关闭 @@ -1404,51 +1409,56 @@ + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + 输入应用程序路径(/usr/bin/...、.desktop 或 .AppImage)。 + + + Enter an http:// or https:// web address. 请输入以 http:// 或 https:// 开头的网址。 - + Enter the text snippet to type. 请输入要键入的文本片段。 - + Invalid action configuration. 操作设置无效。 - + Invalid tile properties. 磁贴属性无效。 - + Media key 媒体键 - + Page not found. 未找到页面。 - + This page already has the maximum number of tiles (24). 此页面的磁贴已达上限(24 个)。 - + Tile not found. 未找到磁贴。 - + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). 请使用单个键(a-z、0-9、f1-f12、enter、tab、space、escape、方向键等)。 - + Web address must start with http:// or https://. 网址必须以 http:// 或 https:// 开头。 @@ -1525,6 +1535,11 @@ Show desktop (Win+D) 显示桌面 (Win+D) + + + Show desktop (Super+D) + 显示桌面 (Super+D) + Switch window (Alt+Tab) @@ -1535,6 +1550,11 @@ Screenshot (Win+Shift+S) 截图 (Win+Shift+S) + + + Screenshot + 截图 + Lock PC @@ -1728,6 +1748,11 @@ Win Win + + + Super + Super + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. %1 在手机上打开 NectarLink,并让两台设备处于同一网络。如果手机询问是否允许在后台运行,请允许。 - + %1 days ago %1 天前 - + %1 hours ago %1 小时前 - + %1 is connected %1 已连接 - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) %1 在此 Wi‑Fi 上,但路由器阻止了直接连接(AP 隔离) - + %1 is set as a public network %1 被设为公用网络 - + %1 isn't connected %1 未连接 - + %1 minutes ago %1 分钟前 - + %1 · %2 ms %1 · %2 毫秒 - + A VPN is on VPN 已开启 - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + 一条防火墙规则阻止了传入的 NectarLink 连接。允许后,手机即可连接。 + + + + Calls and alerts can't play sound yet + 通话和提醒还无法播放声音 + + + Connect it to the same Wi-Fi or wired network as your phone. 请将它连接到与手机相同的 Wi‑Fi 或有线网络。 - + Connected to %1 已连接到 %1 - + Connected to a network 已连接到网络 - + Connecting to %1… 正在连接 %1… - + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + GNOME 只有安装 AppIndicator 扩展后才会在那里显示应用图标。请安装它(gnome-shell-extension-appindicator),并在“扩展”中启用。 + + + + Install fuse3 to browse your phone's files in your file manager. + 安装 fuse3,即可在文件管理器中浏览手机上的文件。 + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + 安装 gstreamer1.0-pipewire 和 gstreamer1.0-tools;如果缺少桌面门户(xdg-desktop-portal),也请一并安装。 + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + 安装 pipewire-pulse(或 pulseaudio-utils),用于通话声音和查找电脑的提示音。 + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + 安装 v4l2loopback-dkms 并重启电脑,应用即可把手机用作摄像头。 + + + It hasn't connected yet. 它还没有连接过。 - + Keep %1 reachable in the background (%2) 让 %1 在后台保持可连接(%2) - + Last seen %1. 上次在线:%1。 - + + NectarLink can't show its icon in the top bar + NectarLink 无法在顶栏中显示图标 + + + + NectarLink is in the top bar + NectarLink 已显示在顶栏中 + + + NectarLink isn't allowed through Windows Firewall Windows 防火墙未允许 NectarLink - + + NectarLink isn't allowed through the firewall + 防火墙未允许 NectarLink + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. 华为 / 荣耀:打开 设置 → 电池 → 应用启动管理,为 NectarLink 关闭“自动管理”,并保持“允许自启动”“允许关联启动”和“允许后台活动”开启。 - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. 一加 / OxygenOS:打开 设置 → 应用 → 应用管理 → NectarLink → 耗电管理,开启“允许后台活动”和“允许自启动”。 - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. OPPO / 真我:打开 设置 → 应用 → 应用管理 → NectarLink → 耗电管理,开启“允许后台活动”和“允许自启动”。 - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. Pixel:打开 设置 → 应用 → 查看所有应用 → NectarLink → 应用的电池用量,点按“允许后台使用”,然后选择“无限制”。 - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. 三星:打开 设置 → 应用程序 → NectarLink → 电池,选择“不受限制”,然后在 设备维护 → 电池 → 后台使用限制 中确保 NectarLink 不会被设为休眠。 - + On USB 通过 USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. vivo / OriginOS / Funtouch:打开 设置 → 电池 → 后台耗电管理 → NectarLink,选择“允许后台高耗电”,并允许自启动。 - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. 小米 / HyperOS / MIUI:打开 设置 → 应用设置 → 应用管理 → NectarLink,开启“自启动”,并将省电策略设为“无限制”。 - + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. 在公用网络上,Windows 会阻止其他设备发现此电脑。如果这是你的家庭或工作网络,请将其设为专用网络。 - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + 在公用网络上,防火墙会阻止其他设备发现此电脑。如果这是你的家庭或工作网络,请打开网络设置,将其防火墙区域设为“Home”或“Trusted”。 + + + On this network 在此网络上 - + + Phone as webcam is ready + “手机作为摄像头”已就绪 + + + + Phone as webcam needs one more step + “手机作为摄像头”还差一步 + + + + Phone storage can open in your file manager + 手机存储可以在文件管理器中打开 + + + + Phone storage needs one more step + 手机存储还差一步 + + + + Showing this PC's screen needs one more step + 显示此电脑的屏幕还差一步 + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. Windows 询问时有人选择了不允许。允许后,手机即可连接。 - + + Sound is ready + 声音已就绪 + + + + The firewall blocks NectarLink + 防火墙阻止了 NectarLink + + + + The firewall is off for this network + 此网络的防火墙已关闭 + + + + The firewall lets your phone connect + 防火墙允许手机连接 + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + 手机上的触控板和键盘需要桌面门户(xdg-desktop-portal)或 /dev/uinput 的访问权限。 + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. 此电脑能看到 %1 在网络上发出的广播,但路由器阻止了设备之间直接通信(通常称为 AP 隔离、客户端隔离或访客 Wi‑Fi)。请让两台设备离开访客 Wi‑Fi,在路由器的 Wi‑Fi 设置中关闭 AP / 客户端隔离,或改用 USB 数据线连接。 - + This PC isn't on a network 此电脑未连接网络 - + This network 此网络 - + This usually takes a few seconds. 通常只需几秒钟。 - + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. VPN 常会阻止同一网络上的设备访问此电脑。如果手机无法连接,请尝试关闭 VPN。 - + While away 离开时 - + Windows Firewall blocks NectarLink Windows 防火墙阻止了 NectarLink - + Windows Firewall is off for this network 此网络的 Windows 防火墙已关闭 - + Windows Firewall lets your phone connect Windows 防火墙允许手机连接 - + + Your phone can control this PC + 手机可以控制此电脑 + + + + Your phone can show this PC's screen + 手机可以显示此电脑的屏幕 + + + + Your phone can't control this PC yet + 手机还无法控制此电脑 + + + Your phone may not be able to connect. Allow it once here. 手机可能无法连接。在这里允许一次即可。 - + a minute ago 1 分钟前 - + an hour ago 1 小时前 - + just now 刚刚 @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow 允许 - + Open settings 打开设置 - + Reconnect 重新连接 - + Connection Doctor 连接诊断 - + Checking network and firewall… 正在检查网络和防火墙… - + Everything looks right. 一切正常。 - + + Your phone can reach this PC. A few features need a step from you: + 手机可以连接到此电脑。有几项功能还需要你操作一步: + + + Issues blocking your phone from reaching this PC: 阻止手机连接到本机的问题: - + Firewall settings 防火墙设置 - + Check again 再次检查 - + Done 完成 @@ -2106,32 +2256,32 @@ Errors - + Something went wrong: %1 出现问题:%1 - + That device isn't paired anymore. 该设备已不再配对。 - + The device declined. 设备已拒绝。 - + The device didn't answer in time. 设备未及时响应。 - + The device isn't connected right now. 设备当前未连接。 - + The device's app doesn't support that yet. 设备上的应用尚不支持此功能。 @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. 文件夹已是最新。 - + Synced %1 file(s). 已同步 %1 个文件。 @@ -2353,85 +2503,85 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. 5. Choose which features to share for that phone and grant only the Android permissions you want to use. - 1. 在 Windows 11 电脑和 Android 手机(Android 8.0 或更高版本)上安装 NectarLink。 + 1. 在电脑(Windows 11 或 Linux)和 Android 手机(Android 8.0 或更高版本)上安装 NectarLink。 2. 让两台设备连接到同一个 Wi‑Fi 网络,或用 USB 数据线将手机连接到电脑。 3. 在设置向导中打开“配对”步骤,或点击左侧栏底部的 +(配对新设备)。 4. 用 NectarLink 安卓应用扫描二维码,或在附近设备列表中选择你的手机,并确认两边屏幕上的 6 位验证码一致。 5. 选择要为这部手机共享的功能,只授予你想使用的 Android 权限。 - + Android permissions and why each is optional Android 权限及为何每项都是可选的 - + Automations 自动化 - + Calls 通话 - + Clipboard 剪贴板 - + Command palette 命令面板 - + Connection Doctor 连接诊断 - + Continuity camera 接续相机 - + Deck 面板 - + Drop shelf 收纳架 - + Elevated and Assist modes 提升模式与辅助模式 - + Features guide 功能指南 - + Features reference 功能参考 - + Handoff 接力 - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2446,17 +2596,17 @@ 4. 你也可以在开启 USB 调试后用 USB 数据线连接;NectarLink 会自动建立本地隧道。 - + Install and first-time setup 安装与首次设置 - + Local HTTP API 本地 HTTP API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2475,17 +2625,17 @@ Sensitive capabilities such as Share PC folders, Remote input, and Reverse PC sc “共享电脑文件夹”“远程输入”和“反向电脑屏幕镜像”等敏感功能,对每台已配对设备默认关闭,直到你打开它们。 - + LocalSend LocalSend - + Messages 消息 - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2508,12 +2658,12 @@ Assist mode (Android Accessibility Service) - NectarLink 绝不会通过无障碍服务读取屏幕文字或记录按键。 - + Nectar Island Nectar 岛 - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2538,7 +2688,7 @@ Android 上的功能层级 - 辅助模式(可选):仅在你开启“远程输入”时使用 Android 无障碍服务,让电脑的鼠标和键盘能够控制手机。 - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2555,97 +2705,97 @@ Android 上的功能层级 - 相机和麦克风:仅在将手机用作摄像头、拍摄接续相机照片或录制语音备忘时才需要。 - + NectarLink overview NectarLink 概览 - + Notifications 通知 - + Phone as speaker 手机作为扬声器 - + Phone as webcam 手机作为摄像头 - + Phone controls 手机控制 - + Photos 照片 - + Privacy and security 隐私与安全 - + Remote input 远程输入 - + Screen mirroring 屏幕镜像 - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. 在功能指南中选择任意功能卡片,即可查看其实时状态,用“带我去”直接跳到对应的控制,或了解它的工作方式和所用的权限。 - + Send files 发送文件 - + Share PC folders 共享电脑文件夹 - + Share PC screen 共享电脑屏幕 - + Timeline 时间线 - + Troubleshooting and Connection Doctor 故障排除与连接诊断 - + Type on phone 在手机上输入 - + Voice recorder 录音机 - + Wake PC 唤醒电脑 - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2666,7 +2816,7 @@ What it needs - 在 Android 手机上授予照片和媒体权限。 - + What it does Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. @@ -2687,7 +2837,7 @@ What it needs - 音量、手电筒和查找手机在标准模式下即可使用;勿扰模式需要“勿扰权限”;在较新的 Android 版本上,Wi‑Fi 和蓝牙开关需要提升模式。 - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2708,7 +2858,7 @@ What it needs - 在 设置 › 文件与同步 中开启“局域网内的 LocalSend”,并让两台设备处于同一本地网络。 - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2727,7 +2877,7 @@ What it needs - 在 设置 › 通用 中开启 Nectar 岛,并选择要显示哪些事件。 - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2746,7 +2896,7 @@ What it needs - 在 设置 › 连接与关于 中开启本地自动化 API,并从 api-token.json 读取持有者令牌。 - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2767,7 +2917,7 @@ What it needs - 手机上的辅助模式(无障碍服务)、NectarLink 输入法或提升模式。 - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2788,7 +2938,7 @@ What it needs - 在电脑上随时可用。 - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2809,7 +2959,7 @@ What it needs - 手机上的文件和媒体访问权限,并在设置中配置至少一条文件夹同步规则。 - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2832,7 +2982,7 @@ What it needs - 手机上的通知使用权(用于消息应用的对话)。 - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2853,7 +3003,7 @@ What it needs - 在 Windows 设备管理器和 BIOS/UEFI 中开启了网络唤醒的网络适配器(有线以太网最可靠)。 - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2874,7 +3024,7 @@ What it needs - 在电脑上为该设备开启“远程鼠标、键盘与面板”开关(或在手机请求控制时通过一次性提示批准)。 - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2899,7 +3049,7 @@ What it needs - 在电脑上为该设备开启“通知”开关。 - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2920,7 +3070,7 @@ What it needs - 随时可用,关闭时不占用后台内存。 - + What it does Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. @@ -2943,7 +3093,7 @@ What it needs - 已配对的手机;最新照片和截图卡片需要手机上的照片权限。 - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2964,7 +3114,7 @@ What it needs - 对所有已配对设备自动生效;保留时长可在 设置 › 存储与隐私 中调整。 - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2985,7 +3135,7 @@ What it needs - 录制语音备忘时需要手机上的麦克风权限。 - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -3004,7 +3154,7 @@ What it needs - 在那里添加并开启一条或多条规则。 - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -3025,7 +3175,7 @@ What it needs - 已配对的手机,并为该设备开启“文件”开关。 - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -3044,7 +3194,7 @@ What it needs - 已配对的手机;在线时立即发送,手机离线时自动排队。 - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -3067,7 +3217,7 @@ What it needs - 在 Android 10 及以上版本上,手机到电脑的后台剪贴板同步通过通知、NectarLink 快捷设置图块或提升模式实现。 - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -3090,7 +3240,7 @@ What it needs - 通话音频仍在手机上(或与手机配对的耳机上);在 Android 12 及以上版本,提升模式可解锁静音、免提、保持和拨号键盘控制。 - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -3111,7 +3261,7 @@ What it needs - 通过本地 Wi‑Fi 或 USB 连接的已配对手机。 - + What it does Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. @@ -3132,7 +3282,7 @@ What it needs - 在电脑上为该设备开启“将电脑屏幕共享到手机”开关,或在手机请求查看屏幕时一次性批准。 - + What it does Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. @@ -3155,7 +3305,7 @@ What it needs - 让其他应用看到它:设置 › 摄像头与音频 中的虚拟摄像头附加组件。 - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -3180,7 +3330,7 @@ What it needs - 辅助模式或提升模式让你可以在镜像窗口中点击、滚动和输入。 - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -3199,7 +3349,7 @@ What it needs - 通过任意活动连接相连的已配对手机。 - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3438,8 +3588,8 @@ What it needs - - + + Wi‑Fi Wi‑Fi @@ -3490,7 +3640,7 @@ What it needs - + Send files 发送文件 @@ -3506,7 +3656,7 @@ What it needs - + Clipboard history 剪贴板历史记录 @@ -3597,14 +3747,14 @@ What it needs - - + + Cancel 取消 - + Timeline 时间线 @@ -3654,448 +3804,458 @@ What it needs 连接 %1 以查看通知 - + + Desktop notifications are off. Turn them on in your system's notification settings. + 桌面通知已关闭。请在系统的通知设置中开启。 + + + Windows notifications are off. Windows 通知已关闭。 - + Turn on 开启 - + Webcam 摄像头 - + Streaming from %1 正在从 %1 传输画面 - - + + Stop 停止 - + Back 后置 - + Front 前置 - + Mirror 镜像 - + Phone controls 手机控制 - + Less 收起 - + Sound & display 声音与显示 - + DND 勿扰 - + Do Not Disturb 勿扰模式 - + Torch 手电筒 - + Flashlight 手电筒 - + BT 蓝牙 - + Bluetooth 蓝牙 - + Hotspot 热点 - + Instant Hotspot 即时热点 - + Phone as speaker 手机作为扬声器 - + Streaming PC audio 正在传输电脑音频 - + Play on phone 在手机上播放 - + Watching on %1 正在 %1 上观看 - + Tap the notification on %1 轻触 %1 上的通知 - + Play a video on this PC first 请先在此电脑上播放视频 - + Turning off Wi‑Fi may disconnect %1. 关闭 Wi‑Fi 可能会断开 %1。 - + Turn off 关闭 - + Ring 响铃 - + Vibrate 振动 - + Silent 静音 - + Volume 音量 - + Brightness 亮度 - + Continuity Camera 接续相机 - + Scan document 扫描文档 - + Phone as webcam 手机作为摄像头 - + Streaming 正在传输 - + Type on phone 在手机上输入 - + Sent: %1 已发送:%1 - + Listening for keys 正在监听按键 - - + + When connected 连接后 - + Start 开始 - - + + Clipboard 剪贴板 - + Type here: keys go to %1 在此输入:按键将发送到 %1 - + Click to focus keyboard 点击以聚焦键盘 - + Ctrl+V pastes · Esc stops Ctrl+V 粘贴 · Esc 停止 - + Paste 粘贴 - + View all 查看全部 - + No recent activity 没有最近活动 - + %1 notifications %1 条通知 - + Show 显示 - + No pop-ups 不弹出提示 - + Hide 隐藏 - + Hidden on this PC. 已在本机隐藏。 - + Shown in feed without pop-ups. 在信息流中显示,但不弹出提示。 - + + Shown in feed and as desktop pop-ups. + 在信息流中显示,并作为桌面弹窗提示。 + + + Shown in feed and as Windows pop-ups. 在信息流中显示,并作为 Windows 弹窗提示。 - - - - + + + + Done 完成 - + Notification history 通知历史记录 - - + + Clear history 清除历史记录 - + Dismissed notifications from the last 24 hours. 最近 24 小时内已忽略的通知。 - + No recent notifications. 没有最近的通知。 - - + + Search notification history 搜索通知历史记录 - - + + Nothing matches “%1”. 没有匹配“%1”的内容。 - + Last 50 shared clips. 最近 50 条共享剪贴内容。 - + No shared clips yet. 还没有共享的剪贴内容。 - - + + Search clipboard history 搜索剪贴板历史记录 - + From %1 来自 %1 - + From phone 来自手机 - + Sent to %1 已发送到 %1 - + From this PC 来自本机 - + Image 图片 - + Pinned 已固定 - + Open on phone 在手机上打开 - + Send to phone 发送到手机 - + Copy 复制 - + Unpin 取消固定 - + Pin 固定 - + Delete 删除 - - + + %1% %1% - + LocalSend nearby · %1 LocalSend 附近 · %1 - + LocalSend nearby LocalSend 附近 - + Refresh 刷新 - + Listening for LocalSend devices on your Wi‑Fi network. 正在您的 Wi‑Fi 网络上监听 LocalSend 设备。 - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). 本机的 LocalSend 端口 53317 已被占用(可能正在运行另一个 LocalSend 应用)。 - + Nearby device 附近设备 - + Image preview 图片预览 - + Close 关闭 - + Open externally 用外部程序打开 @@ -4103,17 +4263,17 @@ What it needs Hotspot - + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. 没有找到 %1 的热点。请确认热点已开启,然后在 Windows 的 Wi-Fi 列表中选择 %2。 - + Connected to %1's hotspot. 已连接到 %1 的热点。 - + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. 无法连接到 %1 的热点。请在 Windows 的 Wi-Fi 列表中选择 %2。 @@ -4216,62 +4376,62 @@ What it needs Links - + %1: tap the notification on your phone to continue watching. %1:轻触手机上的通知以继续观看。 - + %1: tap the notification on your phone to open in Maps. %1:轻触手机上的通知以在地图中打开。 - + Copy a link or address first 请先复制链接或地址 - + Copy a web link (https://…), geo: URI, or street address, then choose this again. 复制网页链接(https://…)、geo: URI 或街道地址,然后再次选择此项。 - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. 复制网页链接、视频网址或街道地址,然后再次选择此项,即可在手机上打开。 - + Couldn't send it 无法发送 - + No phone to open it on 没有可打开它的手机 - + Pair a phone first, or enable opening links on your phone. 请先配对手机,或在手机上启用打开链接。 - + Sent to %1 已发送到 %1 - + Tap the notification on your phone to open it. 轻触手机上的通知以打开。 - + That's not a link or address 这不是链接或地址 - + Update NectarLink on %1. 请更新 %1 上的 NectarLink。 @@ -4387,177 +4547,177 @@ What it needs MainWindow - - + + Home 首页 - - + + Messages 消息 - - + + Calls 通话 - - + + Photos 照片 - - + + Apps 应用 - - + + Deck 面板 - + Guide 指南 - - + + Settings 设置 - + Pair a new device 配对新的设备 - + Back to Home 返回首页 - - + + Timeline 时间线 - + Features guide 功能指南 - + Sharing screen with %1 正在与 %1 共享屏幕 - + Sharing screen 正在共享屏幕 - + Stop 停止 - + Stop sharing this PC's screen 停止共享本机屏幕 - + Commands 命令 - + Command palette (%1) 命令面板(%1) - + Command palette (Ctrl+K) 命令面板(Ctrl+K) - + Files, clips, links, photos, recordings and sessions 文件、剪贴内容、链接、照片、录制和会话 - + Refresh 刷新 - + %1 is ringing this PC %1 正在让本机响铃 - + Stop ringing 停止响铃 - + Allow %1 to control this PC? 允许 %1 控制本机吗? - + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. %1 想要使用本机的鼠标和键盘。你可以在设置中更改。 - - + + Not now 暂不 - - + + Allow 允许 - + Allow %1 to view this PC's screen? 允许 %1 查看本机屏幕吗? - + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. %1 想要查看本机屏幕。如需控制,请在设置中开启远程鼠标、键盘和面板。 - + Allow for this device 允许此设备 - + Photo ready on clipboard 照片已复制到剪贴板 - + Keep on clipboard 保留在剪贴板 - + Paste into active window 粘贴到当前窗口 @@ -4565,17 +4725,17 @@ What it needs Media - + Media is turned off for that device. 已为该设备关闭媒体。 - + That player can't do that. 该播放器无法执行此操作。 - + That player isn't playing anymore. 该播放器已不再播放。 @@ -4583,17 +4743,17 @@ What it needs Messages - + Attachment is %1 (MMS supports up to 900 KB) 附件大小为 %1(彩信最多支持 900 KB) - + Group texts can't be sent from the PC yet. 暂不支持从电脑发送群组短信。 - + Only image or voice-note audio files can be attached. 只能附加图片或语音备忘音频文件。 @@ -4627,286 +4787,286 @@ What it needs - + Yesterday 昨天 - + Text from your PC 从电脑发短信 - + %1 isn't connected %1未连接 - + Messages are off for %1 %1的消息功能已关闭 - + Allow texting on %1 在%1上允许收发短信 - + Couldn't load messages 无法加载消息 - + Pair your phone to read and send texts. 配对手机以阅读和发送短信。 - + Connect %1 to view messages. 连接%1以查看消息。 - + Enable Messages in Settings. 请在设置中启用消息。 - + Grant SMS permission on %1. 请在%1上授予短信权限。 - + Unable to load messages. 无法加载消息。 - + Try again 重试 - + Conversations 会话 - + New message (Ctrl+N) 新建消息 (Ctrl+N) - + Refresh 刷新 - + Search conversations 搜索会话 - + Search 搜索 - + Clear search 清除搜索 - + No conversations match. 没有匹配的会话。 - + No conversations yet. 暂无会话。 - + Drop image to attach (MMS) 拖入图片以添加 (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB PNG、JPEG、WebP、GIF 或 BMP,最大 900 KB - + Select a conversation 选择一个会话 - + Back to conversations (Esc) 返回会话列表 (Esc) - + Call %1 呼叫%1 - + Call 呼叫 - + Remove conversation from PC 从电脑移除会话 - + To 收件人 - - + + Name or phone number 姓名或电话号码 - + No mirrored messages yet 暂无同步的消息 - + No messages 暂无消息 - + %1 · Sent as MMS %1 · 以彩信发送 - + Remove attachment 移除附件 - + Attach image (MMS) 添加图片 (MMS) - + Reply in %1 在%1中回复 - - + + Text message 短信 - + Replies are available while the notification is active on your phone 手机上的通知处于活动状态时才能回复 - + Reply in %1… 在%1中回复… - + Group texts can't be sent from the PC yet 目前无法从电脑发送群组短信 - + Add a caption (optional)… 添加说明文字(可选)… - + Send 发送 - + %1 characters · %n text(s) %1 个字符 · %n 条短信 - + Open image attachment 打开图片附件 - + Voice note 语音留言 - + · Click to play · 点击播放 - + · Loading… · 加载中… - + Attachment (open it on the phone) 附件(请在手机上打开) - + Not sent 未发送 - + Sending… 正在发送… - + Copy text 复制文本 - + Copied 已复制 - + Copy code %1 复制验证码 %1 - + Image attachment 图片附件 - + Close 关闭 - + Open externally 用外部程序打开 - + Done 完成 @@ -4914,102 +5074,117 @@ What it needs Mirror - + Couldn't start recording. 无法开始录制。 - + Mirror recording 镜像录制 - + Mirror screenshot 镜像截图 - + Mirroring is turned off for this phone. 已为此手机关闭镜像。 - + + Recording from %1, saved in Downloads/Nectarlink + 来自 %1 的录制,已保存到 Downloads/Nectarlink + + + Recording from %1, saved in Downloads\Nectarlink 来自 %1 的录制,已保存到 Downloads\Nectarlink - + Saved %1 已保存 %1 - + + Saved %1 to Downloads/Nectarlink. + 已将 %1 保存到 Downloads/Nectarlink。 + + + Saved %1 to Downloads\Nectarlink. 已将 %1 保存到 Downloads\Nectarlink。 - + Screenshot copied to clipboard. 截图已复制到剪贴板。 - + + Screenshot from %1, saved in Downloads/Nectarlink + 来自 %1 的截图,已保存到 Downloads/Nectarlink + + + Screenshot from %1, saved in Downloads\Nectarlink 来自 %1 的截图,已保存到 Downloads\Nectarlink - + Sending %1 files… 正在发送 %1 个文件… - + Sending %1… 正在发送 %1… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. 在手机上的 NectarLink 中设置无线调试,即可在这里打开其应用。 - + Show in folder 在文件夹中显示 - + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. 应用未在手机上打开。请重试,或在手机上打开 NectarLink 检查无线调试。 - + The phone isn't connected. 手机未连接。 - + The screenshot couldn't be copied to the clipboard. 无法将截图复制到剪贴板。 - + The screenshot couldn't be saved. 无法保存截图。 - + This PC can't decode the phone's video. 此电脑无法解码手机的视频。 - + This phone's app doesn't share its screen yet. Update NectarLink on the phone. 此手机上的应用尚不能共享屏幕。请更新手机上的 NectarLink。 - + Wait for the screen to appear first. 请等待屏幕先显示出来。 @@ -5161,7 +5336,7 @@ What it needs Mp4 - + No video frames were recorded yet. 尚未录制任何视频帧。 @@ -5255,37 +5430,47 @@ What it needs Notifications - + Code copied 已复制验证码 - + Copy code 复制验证码 - + Dismissed %1 from %2 已清除 %2 的 %1 条通知 - + Dismissed %1 notifications 已清除 %1 条通知 - + Dismissed from %1 已清除来自 %1 的通知 - + Notification dismissed 已清除通知 - + + Open + 打开 + + + + Reply + 回复 + + + That notification is gone from the phone. 该通知已不在手机上。 @@ -5401,17 +5586,17 @@ What it needs Obs - + OBS isn't reachable on port %1. Turn on its WebSocket server. 无法通过端口 %1 连接 OBS。请开启其 WebSocket 服务器。 - + OBS: %1 OBS:%1 - + The service on that port isn't OBS. 该端口上的服务不是 OBS。 @@ -5582,7 +5767,7 @@ What it needs PcMirror - + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. 找不到播放器的窗口。请在此电脑上重新播放视频,然后打开“在手机上播放”。 @@ -5590,17 +5775,17 @@ What it needs PhoneMic - + Tap Start on %1 to use its microphone. 在 %1 上轻触“开始”以使用其麦克风。 - + The microphone is turned off for this PC on %1. %1 上已为此电脑关闭麦克风。 - + Update NectarLink on %1 to use its microphone. 请更新 %1 上的 NectarLink 以使用其麦克风。 @@ -5608,57 +5793,57 @@ What it needs PhotoBackup - + Can't write to the backup folder. 无法写入备份文件夹。 - + Copying, %1 left 正在复制,还剩 %1 项 - + Looking for new photos… 正在查找新照片… - + Off 已关闭 - + Photos are turned off for this PC on %1. %1 上已为此电脑关闭照片。 - + The backup folder's drive is full. 备份文件夹所在的磁盘已满。 - + The backup stopped. It tries again in a few minutes. 备份已停止,几分钟后会再试。 - + Up to date 已是最新 - + Up to date, %1 backed up 已是最新,已备份 %1 项 - + Update NectarLink on %1 to back up its photos. 请在 %1 上更新 NectarLink 以备份其照片。 - + Waiting for %1 正在等待 %1 @@ -5666,67 +5851,67 @@ What it needs Photos - + Copied to clipboard. 已复制到剪贴板。 - + From %1 来自 %1 - + New photo 新照片 - + New screenshot 新截图 - + No recent photos found on the phone. 手机上没有最近的照片。 - + Opening latest photo… 正在打开最新照片… - + Saved %1 items to %2. 已将 %1 个项目保存到 %2。 - + Saved %1 to %2. 已将 %1 保存到 %2。 - + That photo isn't on the phone anymore. 该照片已不在手机上。 - + The phone doesn't share photos or files with this PC. 手机未与此电脑共享照片或文件。 - + The phone isn't connected right now. 手机当前未连接。 - + The photo couldn't be copied to the clipboard. 无法将照片复制到剪贴板。 - + The photo couldn't be copied. It's in Downloads\Nectarlink. 无法复制照片。它在 Downloads\Nectarlink 中。 @@ -5735,7 +5920,7 @@ What it needs PhotosPage - + All photos 所有照片 @@ -5746,259 +5931,259 @@ What it needs - + Today 今天 - + Yesterday 昨天 - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Choose default folder for saved photos 选择保存照片的默认文件夹 - + Save to folder 保存到文件夹 - + Photos 照片 - - + + %1 isn't connected %1 未连接 - + Pair your phone to browse photos and albums. 配对手机即可浏览照片和相册。 - - + + Connect %1 to view photos. 连接 %1 即可查看照片。 - + Albums 相册 - + Refresh 刷新 - + %1+ items %1+ 个项目 - + 1 item 1 个项目 - + %1 items %1 个项目 - + Cancel 取消 - + Photo 照片 - + Take photo 拍照 - + Scan 扫描 - + Scan document 扫描文档 - + Select 选择 - + Cancel selection 取消选择 - + %1 selected 已选择 %1 个 - + Select items 选择项目 - + Click items to select 点击项目以选择 - + Select all 全选 - - + + Copy 复制 - + Open 打开 - + Save (%1) 保存(%1) - - + + Save 保存 - - + + Save to folder… 保存到文件夹… - + Photos are off for %1 %1 的照片功能已关闭 - + Allow photos on %1 允许在 %1 上使用照片 - + Couldn't load photos 无法加载照片 - + Enable Photos in Settings. 请在设置中启用照片。 - + Grant photos permission on %1. 请在 %1 上授予照片权限。 - + Unable to load photos. 无法加载照片。 - + Try again 重试 - + No photos or videos 没有照片或视频 - + Back to gallery 返回图库 - - + + Play video 播放视频 - + Open in Photos 在照片中打开 - + Loading full resolution… 正在加载完整分辨率… - + Previous photo 上一张照片 - + Next photo 下一张照片 - + %1 %1 - + Select photo 选择照片 @@ -6006,32 +6191,32 @@ What it needs PlayOnPhone - + Connect your phone to play on it. 请连接手机后再在其上播放。 - + Media is turned off for %1. 已为 %1 关闭媒体。 - + Opening in %1 on %2. Tap the notification there. 正在 %2 上用 %1 打开。请轻触那里的通知。 - + Sent to %1. Tap the notification there. 已发送到 %1。请轻触那里的通知。 - + Tap the notification on %1 to watch. 轻触 %1 上的通知即可观看。 - + Update NectarLink on %1 to play on it. 请更新 %1 上的 NectarLink 以在其上播放。 @@ -6039,7 +6224,7 @@ What it needs Prefs - + Connected to OBS. 已连接到 OBS。 @@ -6047,7 +6232,7 @@ What it needs Recordings - + Recording from %1 saved 已保存来自 %1 的录音 @@ -6063,57 +6248,57 @@ What it needs SendTo - + Copied to your phone's clipboard. 已复制到手机的剪贴板。 - + Couldn't reach %1. 无法连接 %1。 - + Couldn't send %1 无法发送 %1 - + Couldn't send to %1 无法发送到 %1 - + Couldn't share with %1 无法与 %1 共享 - + File failed integrity check; partial file kept to resume. 文件未通过完整性校验;已保留部分文件以便继续。 - + Send to %1 with NectarLink 用 NectarLink 发送到 %1 - + Sending to %1… 正在发送到 %1… - + Sent to %1 已发送到 %1 - + The transfer didn't go through. 传输未完成。 - + Waiting for %1… 正在等待 %1… @@ -6162,7 +6347,7 @@ What it needs - + Notifications 通知 @@ -6282,1287 +6467,1334 @@ What it needs 显示通知 - - + + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Messages 消息 - - + + Calls 通话 - + Contacts 联系人 - + Clipboard 剪贴板 - + Files 文件 - - + + Voice recordings 语音录音 - + Media controls 媒体控制 - + Photos 照片 - + Phone controls 手机控制 - + Lock, sleep & wake PC 锁定、睡眠与唤醒电脑 - + Screen mirroring 屏幕镜像 - + Share PC screen to phone 将电脑屏幕共享到手机 - + Webcam 摄像头 - + File Explorer integration 文件资源管理器集成 + File manager integration + 文件管理器集成 + + + Remote mouse, keyboard & Deck 远程鼠标、键盘与面板 - + Run Deck shell commands 运行面板 shell 命令 - + Remote PC file access 远程访问电脑文件 - + Allow this phone to view this PC's screen. 允许此手机查看本机屏幕。 - + Only enable for a phone you trust. 仅对信任的手机启用。 - + Calls, messages & notifications 通话、消息与通知 - + Files, photos & clipboard 文件、照片与剪贴板 - + Screen, camera & control 屏幕、摄像头与控制 - + Back up photos to 将照片备份到 - + Save recordings in 录音保存位置 - + Pair a new device 配对新设备 - + Theme 主题 - + Hive 蜂巢 - + Bloom 绽放 - + Glass 玻璃 - + Graphite 石墨 - + Mode 模式 - - + + System 跟随系统 - + Light 浅色 - + Dark 深色 - + Color 颜色 - + Wallpaper 壁纸 - + AMOLED AMOLED - - - + + + Pure black (AMOLED) 纯黑(AMOLED) - - + + Media artwork 媒体封面 - - + + Animated comb 动态蜂巢 - + + Still while animations are off in system settings. + 系统设置中关闭动画时保持静止。 + + + Still while animations are off in Windows. Windows 关闭动画时保持静止。 - + Still while energy saver is on. 开启节能模式时保持静止。 - + The honeycomb breathes and pulses. 蜂巢会呼吸与脉动。 - + Pitch-black #000000 surfaces. 纯黑 #000000 界面。 - - + + Mica backdrop 云母背景 - + Tint window with desktop wallpaper. 用桌面壁纸为窗口着色。 - - + + Language 语言 - + NectarLink follows Windows unless you pick one. 除非你另行选择,NectarLink 会跟随 Windows 的语言。 - + More 更多 - - + + Start with Windows 随 Windows 启动 - + + + Start at login + 登录时启动 + + + Launch in the tray when you sign in. 登录时在托盘中启动。 - - + + Stay in system tray 保持在系统托盘 - + Keep running when the window is closed. 关闭窗口后继续运行。 - - + + Nectar Island Nectar 岛 - + Top-screen pill for calls, transfers & media. 屏幕顶部的胶囊条,用于通话、传输与媒体。 - - + + Battery alerts 电池提醒 - + Notify when phone battery is low or full. 手机电量低或充满时通知。 - + Island triggers 岛触发条件 - + Choose when the top pill appears. 选择顶部胶囊条何时出现。 - + Transfers 传输 - + Media 媒体 - + Alerts 提醒 - - + + Sync clipboard to phone 同步剪贴板到手机 - + Share copied text and images with your phone. 与手机共享复制的文字和图片。 - - + + Clipboard follows control 剪贴板跟随控制 - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. 开始控制手机或将鼠标移入其窗口时,把剪贴板发送到手机。 - - + + Copy one-time codes 复制一次性验证码 - + Auto-copy verification codes from texts. 自动从短信中复制验证码。 - - + + Copy received files 复制收到的文件 - + Ready to Ctrl+V incoming files anywhere. 随时可按 Ctrl+V 粘贴收到的文件。 - - + + Suggest clipboard actions 建议剪贴板操作 - + Open links, maps, numbers & emails from phone clips. 打开手机剪贴内容中的链接、地图、号码与邮箱。 - + No phones paired yet 尚未配对手机 - + Pair your phone to get started. 配对你的手机即可开始。 - + Pair a phone 配对手机 - + Connected 已连接 - + Not connected 未连接 - - + + Unpair 取消配对 - + Join this phone's hotspot 连接此手机的热点 - + When Instant Hotspot turns it on, this PC joins %1. 即时热点开启后,此电脑会连接到 %1。 - + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + 请先在系统的 Wi-Fi 列表中连接一次手机热点,然后在这里选择它。 + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. 请先在 Windows 的 Wi-Fi 列表中连接一次手机热点,然后在这里选择它。 - + Choose network 选择网络 - - - - - - + + + + + + Open 打开 - - + + Stream PC audio to phone 将电脑音频串流到手机 - + Listen to PC audio through your phone. 通过手机收听电脑音频。 - - + + Pause media during calls 通话时暂停媒体 - + Auto-pause PC media when phone rings. 手机响铃时自动暂停电脑媒体。 - - + + Mute this PC while a phone watches 手机观看时将此电脑静音 - + With Play on phone, the phone still gets the sound. 使用“在手机上播放”时,手机仍会收到声音。 - + Phone as microphone 手机作为麦克风 - + Live from %1, playing into %2. 正在接收 %1,播放到 %2。 - + Tap Start on %1. 请在 %1 上轻触“开始”。 - + Plays into %1, so apps can pick its other end as a microphone. 播放到 %1,应用可以把它的另一端选作麦克风。 - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. 播放到 %1。要在其他应用中用作麦克风,请安装虚拟音频线,例如 VB-CABLE。 - + Output 输出 - + Start 开始 - + Stop 停止 - - + + Microphone volume 麦克风音量 - - + + %1% %1% - + Virtual camera add-on 虚拟摄像头插件 - + Registered for Zoom, Teams & Meet. 已注册到 Zoom、Teams 与 Meet。 - + One-time setup (admin required) for Zoom, Teams & Meet. 为 Zoom、Teams 与 Meet 进行一次性设置(需要管理员权限)。 - - + + Ready 就绪 - - - + + + Remove 移除 - + Set up 设置 - + Phone 手机 - + Camera 摄像头 - + Back 后置 - + Front 前置 - - + + Mirror image 镜像 - + Resolution 分辨率 - + Preview · %1 预览 · %1 - + Pair a phone to use its camera as a webcam 配对手机即可将其摄像头用作电脑摄像头 - + Stop webcam 停止摄像头 - + Start webcam 启动摄像头 - - + + Follow phone Do not disturb 跟随手机免打扰 - + Mute pop-ups while phone is on DND. 手机处于免打扰时静音弹窗。 - - + + Forward PC notifications 转发电脑通知 - + Send PC alerts to phone while away. 离开时把电脑提醒发送到手机。 - + Quiet hours 免打扰时段 - + Configure automatic DND schedule in Windows. 在 Windows 中配置自动免打扰时间表。 - + Set a schedule 设置时间表 - + Show 显示 - + No pop-ups 不弹出提示 - + Hide 隐藏 - - + + File Explorer Send to 文件资源管理器“发送到” - + + + File manager Send to + 文件管理器“发送到” + + + Send files from right-click menu. 从右键菜单发送文件。 - - + + Your PC in your pocket 口袋里的电脑 - + Share Downloads, Documents, Desktop & Pictures. 共享下载、文档、桌面和图片文件夹。 - - + + LocalSend on local network 局域网内的 LocalSend - + Share with nearby LocalSend devices without pairing. 无需配对即可与附近的 LocalSend 设备共享。 - - + + Open files shared from the phone 打开手机共享的文件 - + Opens a shared photo or document as soon as it arrives. 手机共享的照片或文档一到就自动打开。 - - - - - + + + + + Reset 重置 - - + + Choose folder 选择文件夹 - + Photo backup 照片备份 - + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. 将所有相册中的新照片按年和月复制到此电脑上的文件夹。电脑和手机上的内容都不会被删除。只需要手机的照片权限。 - + Back up photos from %1 备份 %1 的照片 - + Backup folder 备份文件夹 - + Include videos 包括视频 - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. 视频可能很大。逐个复制,因此连接中断最多只影响一个文件。 - + Back up now 立即备份 - + Synced folders 同步文件夹 - + One-way or two-way folder sync between phone and PC. 手机与电脑之间的单向或双向文件夹同步。 - + Sync now 立即同步 - + Add DCIM → Pictures 添加 DCIM → 图片 - + Two-way sync · conflict copies kept 双向同步 · 保留冲突副本 - + One-way · phone to PC 单向 · 手机到电脑 - + Command palette 命令面板 - + Open from any app. 可在任何应用中打开。 - + Command palette shortcut 命令面板快捷键 - + File shelf 文件收纳架 - + Toggle screen-edge shelf. 切换屏幕边缘收纳架。 - + Shelf shortcut 收纳架快捷键 - + Take photo 拍照 - + Capture & paste photo. 拍摄并粘贴照片。 - + Take photo shortcut 拍照快捷键 - + Scan document 扫描文档 - + Scan & paste document. 扫描并粘贴文档。 - + Scan document shortcut 扫描文档快捷键 - + Trigger-action rules 触发-动作规则 - + Run actions when your phone connects, disconnects, or rings. 手机连接、断开或响铃时执行动作。 - + Lock PC on disconnect 断开时锁定电脑 - + Pause media on ring 响铃时暂停媒体 - + OBS Studio OBS Studio - + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. 用于运行 obs: 命令的面板磁贴。请在 OBS 的“工具 › WebSocket 服务器设置”中开启 WebSocket 服务器。 - + Port 端口 - + OBS WebSocket port OBS WebSocket 端口 - + Password 密码 - + OBS WebSocket password OBS WebSocket 密码 - + Saved 已保存 - + None 无 - + Save 保存 - + Test connection 测试连接 - + Remove password 移除密码 - - + + Clipboard history 剪贴板历史记录 - + %1 clips · %2 · kept 7 days %1 条剪贴板记录 · %2 · 保留 7 天 - - - - - - + + + + + + Clear 清除 - - + + Notification history 通知历史记录 - + %1 alerts · %2 · kept 24h %1 条提醒 · %2 · 保留 24 小时 - + Cross-device timeline 跨设备时间线 - + 1 item stored (max 5,000). 已存储 1 个项目(上限 5,000)。 - + %1 items stored (max 5,000). 已存储 %1 个项目(上限 5,000)。 - + 30d 30 天 - + 90d 90 天 - + 1y 1 年 - + All 全部 - + Cached messages 缓存的消息 - + 1 chat 1 个会话 - + %1 chats %1 个会话 - + 1 message 1 条消息 - + %1 messages %1 条消息 - + %1 · %2 · %3 · kept 90 days %1 · %2 · %3 · 保留 90 天 - + Photo thumbnails 照片缩略图 - + %1 cached · %2 · kept 30 days 已缓存 %1 · %2 · 保留 30 天 - + Transfer history 传输历史记录 - + %1 records · kept with timeline %1 条记录 · 随时间线保留 - + Clear all local history & caches 清除所有本地历史记录和缓存 - + Wipes local history and caches; keeps paired devices and settings. 清除本地历史记录和缓存;保留已配对设备和设置。 - + Clear everything… 清除全部… - + Connection Doctor 连接诊断 - + Diagnose and fix firewall, network, or VPN issues. 诊断并修复防火墙、网络或 VPN 问题。 - + Check 检查 - + Connect over mobile data 通过移动数据连接 - + Stay connected over LTE/5G or relay when away from Wi-Fi. 离开 Wi-Fi 时通过 LTE/5G 或中继保持连接。 - + Wake from your phone 从手机唤醒 - + No active network adapter found. 未找到可用的网络适配器。 - + %1 · Wake on Magic Packet ready. %1 · Magic Packet 网络唤醒已就绪。 - + %1 · Enable Wake on Magic Packet in Device Manager. %1 · 请在设备管理器中启用 Magic Packet 网络唤醒。 - + Off in Windows 在 Windows 中已关闭 - + + Off in the adapter's settings + 在网卡设置中已关闭 + + + Check settings 检查设置 - + No adapter 无适配器 - - + + Local automation API 本地自动化 API - + Listening on 127.0.0.1:%1 · Token in %2 正在监听 127.0.0.1:%1 · 令牌位于 %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. 供脚本和工具使用的本地 HTTP/SSE 服务器(127.0.0.1:%1)。 - + NectarLink %1 NectarLink %1 - + This PC: %1 本机:%1 - + Update download 更新下载 - + Update to %1 更新到 %1 - - - + + + Check for updates 检查更新 - + Open logs 打开日志 - + Looks for a new version on GitHub once a day. 每天在 GitHub 上检查一次新版本。 - - + + Install updates automatically 自动安装更新 - + When nobody is using the PC and nothing is in progress. 在无人使用电脑且没有任务进行时。 - + Setup walkthrough 设置引导 - + Re-open the setup guide. 重新打开设置指南。 - + Features guide 功能指南 - + What each feature needs and where to find it. 了解各项功能需要什么以及在哪里找到。 - + Phone's hotspot network 手机热点网络 - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + 此电脑以前连接过的 Wi-Fi 网络。密码由系统保存,NectarLink 不会看到。 + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + 此电脑还没有保存任何 Wi-Fi 网络。请先在系统的 Wi-Fi 列表中连接一次手机热点。 + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. 此电脑以前连接过的 Wi-Fi 网络。密码由 Windows 保存,NectarLink 不会看到。 - + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. 此电脑还没有保存任何 Wi-Fi 网络。请先在 Windows 的 Wi-Fi 列表中连接一次手机热点。 - + Don't join 不连接 - + Play the phone's microphone into 将手机麦克风播放到 - - - + + + Close 关闭 - + + NectarLink follows the system unless you pick one. + 除非你另行选择,NectarLink 会跟随系统语言。 + + + Unpair %1? 取消配对 %1? - + Both devices forget each other. You can pair them again any time. 两台设备将互相遗忘。你可以随时重新配对。 - - + + Cancel 取消 - + Clear all local history & caches? 清除所有本地历史记录和缓存? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. 删除本机的剪贴板历史记录、时间线、通知历史记录、缓存的消息、照片缩略图和传输历史记录。已配对设备、设置和已下载的文件会保留。 - + Clear everything 清除全部 @@ -7570,25 +7802,35 @@ What it needs Setup - + Couldn't start setup: %1 无法启动安装程序:%1 - + + Couldn't uninstall NectarLink: %1 + 无法卸载 NectarLink:%1 + + + Setup is taking too long. Try again after restarting the PC. 安装时间过长。请重启电脑后再试。 - + Setup stopped with code %1. 安装已停止,代码 %1。 - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. 缺少卸载程序。请重新安装 NectarLink,然后再卸载。 + + + Uninstall stopped with code %1. + 卸载已停止,代码 %1。 + SetupWindow @@ -7790,164 +8032,164 @@ What it needs 手机 - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + Shelf 收纳架 - + No phone connected 未连接手机 - + Refresh Shelf 刷新收纳架 - + Close Shelf 关闭收纳架 - + Release to send to %1 松开以发送到 %1 - + Sent to %1 已发送到 %1 - + Send cancelled 已取消发送 - + Drop or flick items here to send 将项目拖放到此处或向上轻扫即可发送 - + Sending to %1… 正在发送到 %1… - + Undo 撤销 - + Latest photo 最新照片 - + No recent photo 暂无近期照片 - + Latest screenshot 最新截图 - + No recent screenshot 暂无近期截图 - + Copy 复制 - + Open 打开 - + Current clip 当前剪贴内容 - + Send clip to phone 发送剪贴内容到手机 - - + + Clip 剪贴内容 - + Copy clip 复制剪贴内容 - - + + Clipboard is empty 剪贴板为空 - + Current clip: %1 当前剪贴内容:%1 - + Recent received files 最近接收的文件 - + No received files yet 暂无接收的文件 - + Send to phone 发送到手机 - + Open file 打开文件 - + Show in folder 在文件夹中显示 - + Flick any card up to send to phone, drag out to use, or drop files in 向上轻扫任意卡片即可发送到手机,拖出即可使用,也可将文件拖入 @@ -7955,147 +8197,147 @@ What it needs Timeline - + App window #%1 应用窗口 #%1 - + Clip (removed from clipboard history) 剪贴内容(已从剪贴板历史中删除) - + Copied image 已复制的图片 - + Copied link to clipboard. 已将链接复制到剪贴板。 - + Copied to clipboard. 已复制到剪贴板。 - + From %1 来自 %1 - + In progress 进行中 - + No phone is paired yet. 尚未配对手机。 - + Opened from %1 从 %1 打开 - + Opened on %1 在 %1 上打开 - + Phone microphone 手机麦克风 - + Phone webcam 手机摄像头 - + Recorded on %1 在 %1 上录制 - + Saved from %1 从 %1 保存 - + Screen mirroring 屏幕镜像 - + Sending to %1… 正在发送到 %1… - + Sent clip to %1. 已将剪贴内容发送到 %1。 - + Sent to %1 已发送到 %1 - + Session 会话 - + That clip is no longer in clipboard history. 该剪贴内容已不在剪贴板历史中。 - + That entry couldn't be removed. Try again. 无法删除该条目,请重试。 - + That file was moved or deleted. 该文件已被移动或删除。 - + That isn't a web link. 这不是网页链接。 - + That photo was moved or deleted. 该照片已被移动或删除。 - + The Timeline couldn't be cleared. Try again. 无法清除时间线,请重试。 - + The photo couldn't be copied to the clipboard. 无法将照片复制到剪贴板。 - + Voice recording 录音 - + With %1 与 %1 - + device 设备 @@ -8140,8 +8382,8 @@ What it needs - - + + All devices 所有设备 @@ -8156,92 +8398,92 @@ What it needs 昨天 - + Search timeline 搜索时间线 - + Search timeline… 搜索时间线… - + Clear search 清除搜索 - + Clear all 全部清除 - + Device filter: %1 设备筛选:%1 - + Clear timeline history? Saved files on disk are kept. 清除时间线历史记录?已保存到磁盘的文件会保留。 - + Cancel 取消 - + Clear timeline 清除时间线 - + No matching items 没有匹配的项目 - + No recent activity 暂无近期活动 - + Clear the search or filter. 请清除搜索或筛选条件。 - + Shared files, clips, links, and photos appear here. 共享的文件、片段、链接和照片会显示在这里。 - + Open 打开 - + Show in folder 在文件夹中显示 - + Copy 复制 - + Send again 再次发送 - + Remove from timeline 从时间线移除 - + Load more 加载更多 @@ -8387,37 +8629,37 @@ What it needs Transfers - + Accept in NectarLink to save to Downloads\Nectarlink 在 NectarLink 中接受即可保存到 Downloads\Nectarlink - + Files are turned off for that device. 已为该设备关闭文件。 - + From %1, opened from Downloads\Nectarlink 来自 %1,已从 Downloads\Nectarlink 打开 - + From %1, saved in Downloads\Nectarlink 来自 %1,已保存到 Downloads\Nectarlink - + Nearby device 附近的设备 - + That's too many files to send at once. 文件太多,无法一次发送。 - + There's nothing to send in there. 那里没有可发送的内容。 @@ -8425,67 +8667,67 @@ What it needs Updater - + Couldn't check for updates. Check your internet connection. 无法检查更新。请检查网络连接。 - + NectarLink %1 is available NectarLink %1 已发布 - + NectarLink %1 is available. NectarLink %1 已发布。 - + NectarLink is still on the version you had. Try again from Settings. NectarLink 仍是原来的版本。请在设置中重试。 - + NectarLink is up to date. NectarLink 已是最新版本。 - + NectarLink is updated to %1 NectarLink 已更新到 %1 - + The download was damaged, so it wasn't installed. Try again. 下载的文件已损坏,因此未安装。请重试。 - + The update couldn't be downloaded. Check your internet connection and try again. 无法下载更新。请检查网络连接后重试。 - + The update couldn't be saved. Check that there's free space and try again. 无法保存更新。请确认有足够的空间后重试。 - + The update couldn't be started. Try again. 无法启动更新。请重试。 - + The update didn't install 更新未安装 - + Update now, or later from Settings. 立即更新,或稍后在设置中更新。 - + Your phone reconnects on its own. 手机会自动重新连接。 @@ -8493,191 +8735,202 @@ What it needs WalkthroughPage - + Skip setup 跳过设置 - + Notifications and Calls 通知与通话 - + Clipboard and Send files 剪贴板与发送文件 - + Mirroring, Camera, and Audio 投屏、摄像头与音频 - + Direct local QUIC link 本地 QUIC 直连 - + End-to-end encrypted over QUIC 基于 QUIC 的端到端加密 - + Hardware-backed device keys 硬件级设备密钥 - + Local network by default 默认走本地网络 - + Option 1: Download the Android APK (Android 8.0+) 方式一:下载安卓 APK(Android 8.0 或更新版本) - + Open the latest GitHub Release on your phone or copy the release link. 在手机上打开最新的 GitHub Release,或复制发布链接。 - + Open release page 打开发布页 - + Option 2: Install over USB with ADB 方式二:用 ADB 通过 USB 安装 - + Paired with %1. You can continue to the next step. 已与 %1 配对。你可以继续下一步。 - - + + your phone 你的手机 - - + + On by default 默认开启 - - + + Off until you turn them on 开启前一直关闭 - + Change these per phone on Home after pairing. 配对后可在首页按手机分别修改。 - + Sharing settings for %1 %1 的共享设置 - + Notifications 通知 - + Clipboard 剪贴板 - + Send files 发送文件 - + Now playing 正在播放 - + Messages 消息 - + Calls 通话 - + Screen mirroring 屏幕镜像 - + Remote input 远程输入 - + Share PC folders 共享电脑文件夹 - + Windows notifications Windows 通知 - + + Desktop notifications + 桌面通知 + + + Windows Settings Windows 设置 - + Recheck 重新检测 - - + + Start with Windows 随 Windows 启动 + + Start at login + 登录时启动 + + + Keep NectarLink ready in the system tray when you sign in. 登录后让 NectarLink 常驻系统托盘待命。 - + Firewall and network check 防火墙与网络检查 - + Command Palette (Ctrl+K) 命令面板(Ctrl+K) - + Features guide 功能指南 - + Back 返回 - + Open features guide 打开功能指南 @@ -8685,12 +8938,12 @@ What it needs Webcam - + Starting camera on %1… 正在启动 %1 上的相机… - + Streaming %1 × %2 · %3 fps from %4 正在从 %4 传输 %1 × %2 · %3 fps @@ -8721,17 +8974,17 @@ What it needs Widget - + No phone connected 未连接手机 - + No recent clipboard items 没有最近的剪贴板内容 - + Not available 不可用 diff --git a/desktop/app/i18n/rust_strings.h b/desktop/app/i18n/rust_strings.h index 29ee72c1..f88cc626 100644 --- a/desktop/app/i18n/rust_strings.h +++ b/desktop/app/i18n/rust_strings.h @@ -170,6 +170,7 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Deck", "Choose a key for the shortcut."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Choose an application (.exe or .lnk) on this PC."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter a command to run on this PC."), // desktop/app/src/deck.rs + QT_TRANSLATE_NOOP("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage)."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter an http:// or https:// web address."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter the text snippet to type."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Invalid action configuration."), // desktop/app/src/deck.rs @@ -190,14 +191,24 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "%1 minutes ago"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "%1 · %2 ms"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "A VPN is on"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Calls and alerts can't play sound yet"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connect it to the same Wi-Fi or wired network as your phone."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connected to %1"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connected to a network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connecting to %1…"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install fuse3 to browse your phone's files in your file manager."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera."), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "It hasn't connected yet."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Keep %1 reachable in the background (%2)"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Last seen %1."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink can't show its icon in the top bar"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink is in the top bar"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "NectarLink isn't allowed through Windows Firewall"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink isn't allowed through the firewall"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch."), // desktop/app/src/doctor.rs @@ -207,8 +218,19 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On this network"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone as webcam is ready"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone as webcam needs one more step"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone storage can open in your file manager"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone storage needs one more step"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Showing this PC's screen needs one more step"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Sound is ready"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall blocks NectarLink"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall is off for this network"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall lets your phone connect"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput."), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This PC isn't on a network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This network"), // desktop/app/src/doctor.rs @@ -218,6 +240,9 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "Windows Firewall blocks NectarLink"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Windows Firewall is off for this network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Windows Firewall lets your phone connect"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can control this PC"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can show this PC's screen"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can't control this PC yet"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Your phone may not be able to connect. Allow it once here."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "a minute ago"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "an hour ago"), // desktop/app/src/doctor.rs @@ -230,7 +255,7 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Errors", "The device's app doesn't support that yet."), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("FolderSync", "Folders are up to date."), // desktop/app/src/folder_sync.rs QT_TRANSLATE_NOOP("FolderSync", "Synced %1 file(s)."), // desktop/app/src/folder_sync.rs - QT_TRANSLATE_NOOP("Guide", "1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer).\n2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n5. Choose which features to share for that phone and grant only the Android permissions you want to use."), // desktop/app/src/bridge/app.rs + QT_TRANSLATE_NOOP("Guide", "1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer).\n2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n5. Choose which features to share for that phone and grant only the Android permissions you want to use."), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Android permissions and why each is optional"), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Automations"), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Calls"), // desktop/app/src/bridge/app.rs @@ -324,10 +349,13 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Mirror", "Mirror recording"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Mirror screenshot"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Mirroring is turned off for this phone."), // desktop/app/src/mirror.rs + QT_TRANSLATE_NOOP("Mirror", "Recording from %1, saved in Downloads/Nectarlink"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Recording from %1, saved in Downloads\\Nectarlink"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Saved %1"), // desktop/app/src/mirror.rs + QT_TRANSLATE_NOOP("Mirror", "Saved %1 to Downloads/Nectarlink."), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Saved %1 to Downloads\\Nectarlink."), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Screenshot copied to clipboard."), // desktop/app/src/mirror.rs + QT_TRANSLATE_NOOP("Mirror", "Screenshot from %1, saved in Downloads/Nectarlink"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Screenshot from %1, saved in Downloads\\Nectarlink"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Sending %1 files…"), // desktop/app/src/mirror.rs QT_TRANSLATE_NOOP("Mirror", "Sending %1…"), // desktop/app/src/mirror.rs @@ -347,6 +375,8 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Notifications", "Dismissed %1 notifications"), // desktop/app/src/bridge/notifications.rs QT_TRANSLATE_NOOP("Notifications", "Dismissed from %1"), // desktop/app/src/bridge/notifications.rs QT_TRANSLATE_NOOP("Notifications", "Notification dismissed"), // desktop/app/src/bridge/notifications.rs + QT_TRANSLATE_NOOP("Notifications", "Open"), // desktop/app/src/linux/toast.rs + QT_TRANSLATE_NOOP("Notifications", "Reply"), // desktop/app/src/linux/toast.rs QT_TRANSLATE_NOOP("Notifications", "That notification is gone from the phone."), // desktop/app/src/notifications.rs QT_TRANSLATE_NOOP("Obs", "OBS isn't reachable on port %1. Turn on its WebSocket server."), // desktop/app/src/obs.rs QT_TRANSLATE_NOOP("Obs", "OBS: %1"), // desktop/app/src/obs.rs diff --git a/desktop/app/i18n/template/nectarlink.ts b/desktop/app/i18n/template/nectarlink.ts index 34c3f103..246b16d2 100644 --- a/desktop/app/i18n/template/nectarlink.ts +++ b/desktop/app/i18n/template/nectarlink.ts @@ -177,57 +177,62 @@ - + + Opening apps in windows isn't supported on this phone. + + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. - + Search apps - + Search %1's apps - + Refresh - + No apps match. - + No apps to open. - + Getting the phone's apps… - + Try again - + Recent - + All apps - + %1, open @@ -430,277 +435,277 @@ - + Yesterday - + %1s - + Call from your PC - - - + + + %1 isn't connected - + Pair your phone to view calls and contacts. - + Connect %1 to view calls and contacts. - + Recent - - + + Contacts - + Refresh - - + + Search calls - - + + Search contacts - + Clear search - + Calls are off for %1 - + Allow call log on %1 - + Couldn't load recent calls - + Enable Calls in Settings. - + Grant call log permission on %1. - + Connect %1 to view recent calls. - + Unable to load call log. - - + + Try again - + No calls match. - + No recent calls. - + Contacts are off for %1 - + Allow contacts on %1 - + Couldn't load contacts - + Enable Contacts in Settings. - + Grant contacts permission on %1. - + Connect %1 to view contacts. - + Unable to load contacts. - + No contacts match. - + No contacts on this phone. - + Close contact - + Copy number - - - + + + Text %1 - - - + + + Call %1 - + Phone number to call - + Enter a number - + Backspace - + Text - + Calling… - + Call - + Clear number - + PC call audio connected - + PC call audio ready - + PC call audio (Bluetooth) - + Connect PC audio - + Open Bluetooth settings - + Missed - + Outgoing - + Declined - + Incoming - + Favorites - + +%1 more @@ -708,22 +713,22 @@ CaptionButtons - + Minimize - + Restore - + Maximize - + Close @@ -1404,51 +1409,56 @@ - Enter an http:// or https:// web address. + Enter an application path (/usr/bin/..., .desktop, or .AppImage). - Enter the text snippet to type. + Enter an http:// or https:// web address. - Invalid action configuration. + Enter the text snippet to type. - Invalid tile properties. + Invalid action configuration. - Media key + Invalid tile properties. - Page not found. + Media key - This page already has the maximum number of tiles (24). + Page not found. - Tile not found. + This page already has the maximum number of tiles (24). - Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). + Tile not found. + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). + + + + Web address must start with http:// or https://. @@ -1525,6 +1535,11 @@ Show desktop (Win+D) + + + Show desktop (Super+D) + + Switch window (Alt+Tab) @@ -1535,6 +1550,11 @@ Screenshot (Win+Shift+S) + + + Screenshot + + Lock PC @@ -1728,6 +1748,11 @@ Win + + + Super + + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. - + %1 days ago - + %1 hours ago - + %1 is connected - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) - + %1 is set as a public network - + %1 isn't connected - + %1 minutes ago - + %1 · %2 ms - + A VPN is on - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + + + + + Calls and alerts can't play sound yet + + + + Connect it to the same Wi-Fi or wired network as your phone. - + Connected to %1 - + Connected to a network - + Connecting to %1… - + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + + + + + Install fuse3 to browse your phone's files in your file manager. + + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + + + + It hasn't connected yet. - + Keep %1 reachable in the background (%2) - + Last seen %1. - + + NectarLink can't show its icon in the top bar + + + + + NectarLink is in the top bar + + + + NectarLink isn't allowed through Windows Firewall - + + NectarLink isn't allowed through the firewall + + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. - + On USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. - + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + + + + On this network - + + Phone as webcam is ready + + + + + Phone as webcam needs one more step + + + + + Phone storage can open in your file manager + + + + + Phone storage needs one more step + + + + + Showing this PC's screen needs one more step + + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. - + + Sound is ready + + + + + The firewall blocks NectarLink + + + + + The firewall is off for this network + + + + + The firewall lets your phone connect + + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. - + This PC isn't on a network - + This network - + This usually takes a few seconds. - + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. - + While away - + Windows Firewall blocks NectarLink - + Windows Firewall is off for this network - + Windows Firewall lets your phone connect - + + Your phone can control this PC + + + + + Your phone can show this PC's screen + + + + + Your phone can't control this PC yet + + + + Your phone may not be able to connect. Allow it once here. - + a minute ago - + an hour ago - + just now @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow - + Open settings - + Reconnect - + Connection Doctor - + Checking network and firewall… - + Everything looks right. - + + Your phone can reach this PC. A few features need a step from you: + + + + Issues blocking your phone from reaching this PC: - + Firewall settings - + Check again - + Done @@ -2106,32 +2256,32 @@ Errors - + Something went wrong: %1 - + That device isn't paired anymore. - + The device declined. - + The device didn't answer in time. - + The device isn't connected right now. - + The device's app doesn't support that yet. @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. - + Synced %1 file(s). @@ -2353,8 +2503,8 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. @@ -2362,72 +2512,72 @@ - + Android permissions and why each is optional - + Automations - + Calls - + Clipboard - + Command palette - + Connection Doctor - + Continuity camera - + Deck - + Drop shelf - + Elevated and Assist modes - + Features guide - + Features reference - + Handoff - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2437,17 +2587,17 @@ - + Install and first-time setup - + Local HTTP API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2459,17 +2609,17 @@ Sensitive capabilities such as Share PC folders, Remote input, and Reverse PC sc - + LocalSend - + Messages - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2483,12 +2633,12 @@ Assist mode (Android Accessibility Service) - + Nectar Island - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2503,7 +2653,7 @@ Capability tiers on Android - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2514,97 +2664,97 @@ Capability tiers on Android - + NectarLink overview - + Notifications - + Phone as speaker - + Phone as webcam - + Phone controls - + Photos - + Privacy and security - + Remote input - + Screen mirroring - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. - + Send files - + Share PC folders - + Share PC screen - + Timeline - + Troubleshooting and Connection Doctor - + Type on phone - + Voice recorder - + Wake PC - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2617,7 +2767,7 @@ What it needs - + What it does Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. @@ -2630,7 +2780,7 @@ What it needs - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2643,7 +2793,7 @@ What it needs - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2655,7 +2805,7 @@ What it needs - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2667,7 +2817,7 @@ What it needs - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2680,7 +2830,7 @@ What it needs - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2693,7 +2843,7 @@ What it needs - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2706,7 +2856,7 @@ What it needs - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2720,7 +2870,7 @@ What it needs - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2733,7 +2883,7 @@ What it needs - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2746,7 +2896,7 @@ What it needs - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2761,7 +2911,7 @@ What it needs - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2774,7 +2924,7 @@ What it needs - + What it does Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. @@ -2788,7 +2938,7 @@ What it needs - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2801,7 +2951,7 @@ What it needs - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2814,7 +2964,7 @@ What it needs - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -2826,7 +2976,7 @@ What it needs - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -2839,7 +2989,7 @@ What it needs - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -2851,7 +3001,7 @@ What it needs - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -2865,7 +3015,7 @@ What it needs - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -2879,7 +3029,7 @@ What it needs - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -2892,7 +3042,7 @@ What it needs - + What it does Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. @@ -2905,7 +3055,7 @@ What it needs - + What it does Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. @@ -2919,7 +3069,7 @@ What it needs - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -2934,7 +3084,7 @@ What it needs - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -2946,7 +3096,7 @@ What it needs - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3177,8 +3327,8 @@ What it needs - - + + Wi‑Fi @@ -3229,7 +3379,7 @@ What it needs - + Send files @@ -3245,7 +3395,7 @@ What it needs - + Clipboard history @@ -3336,14 +3486,14 @@ What it needs - - + + Cancel - + Timeline @@ -3393,448 +3543,458 @@ What it needs - + + Desktop notifications are off. Turn them on in your system's notification settings. + + + + Windows notifications are off. - + Turn on - + Webcam - + Streaming from %1 - - + + Stop - + Back - + Front - + Mirror - + Phone controls - + Less - + Sound & display - + DND - + Do Not Disturb - + Torch - + Flashlight - + BT - + Bluetooth - + Hotspot - + Instant Hotspot - + Phone as speaker - + Streaming PC audio - + Play on phone - + Watching on %1 - + Tap the notification on %1 - + Play a video on this PC first - + Turning off Wi‑Fi may disconnect %1. - + Turn off - + Ring - + Vibrate - + Silent - + Volume - + Brightness - + Continuity Camera - + Scan document - + Phone as webcam - + Streaming - + Type on phone - + Sent: %1 - + Listening for keys - - + + When connected - + Start - - + + Clipboard - + Type here: keys go to %1 - + Click to focus keyboard - + Ctrl+V pastes · Esc stops - + Paste - + View all - + No recent activity - + %1 notifications - + Show - + No pop-ups - + Hide - + Hidden on this PC. - + Shown in feed without pop-ups. - + + Shown in feed and as desktop pop-ups. + + + + Shown in feed and as Windows pop-ups. - - - - + + + + Done - + Notification history - - + + Clear history - + Dismissed notifications from the last 24 hours. - + No recent notifications. - - + + Search notification history - - + + Nothing matches “%1”. - + Last 50 shared clips. - + No shared clips yet. - - + + Search clipboard history - + From %1 - + From phone - + Sent to %1 - + From this PC - + Image - + Pinned - + Open on phone - + Send to phone - + Copy - + Unpin - + Pin - + Delete - - + + %1% - + LocalSend nearby · %1 - + LocalSend nearby - + Refresh - + Listening for LocalSend devices on your Wi‑Fi network. - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). - + Nearby device - + Image preview - + Close - + Open externally @@ -3842,17 +4002,17 @@ What it needs Hotspot - + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. - + Connected to %1's hotspot. - + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. @@ -3955,62 +4115,62 @@ What it needs Links - + %1: tap the notification on your phone to continue watching. - + %1: tap the notification on your phone to open in Maps. - + Copy a link or address first - + Copy a web link (https://…), geo: URI, or street address, then choose this again. - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. - + Couldn't send it - + No phone to open it on - + Pair a phone first, or enable opening links on your phone. - + Sent to %1 - + Tap the notification on your phone to open it. - + That's not a link or address - + Update NectarLink on %1. @@ -4126,177 +4286,177 @@ What it needs MainWindow - - + + Home - - + + Messages - - + + Calls - - + + Photos - - + + Apps - - + + Deck - + Guide - - + + Settings - + Pair a new device - + Back to Home - - + + Timeline - + Features guide - + Sharing screen with %1 - + Sharing screen - + Stop - + Stop sharing this PC's screen - + Commands - + Command palette (%1) - + Command palette (Ctrl+K) - + Files, clips, links, photos, recordings and sessions - + Refresh - + %1 is ringing this PC - + Stop ringing - + Allow %1 to control this PC? - + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. - - + + Not now - - + + Allow - + Allow %1 to view this PC's screen? - + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. - + Allow for this device - + Photo ready on clipboard - + Keep on clipboard - + Paste into active window @@ -4304,17 +4464,17 @@ What it needs Media - + Media is turned off for that device. - + That player can't do that. - + That player isn't playing anymore. @@ -4322,17 +4482,17 @@ What it needs Messages - + Attachment is %1 (MMS supports up to 900 KB) - + Group texts can't be sent from the PC yet. - + Only image or voice-note audio files can be attached. @@ -4366,286 +4526,286 @@ What it needs - + Yesterday - + Text from your PC - + %1 isn't connected - + Messages are off for %1 - + Allow texting on %1 - + Couldn't load messages - + Pair your phone to read and send texts. - + Connect %1 to view messages. - + Enable Messages in Settings. - + Grant SMS permission on %1. - + Unable to load messages. - + Try again - + Conversations - + New message (Ctrl+N) - + Refresh - + Search conversations - + Search - + Clear search - + No conversations match. - + No conversations yet. - + Drop image to attach (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB - + Select a conversation - + Back to conversations (Esc) - + Call %1 - + Call - + Remove conversation from PC - + To - - + + Name or phone number - + No mirrored messages yet - + No messages - + %1 · Sent as MMS - + Remove attachment - + Attach image (MMS) - + Reply in %1 - - + + Text message - + Replies are available while the notification is active on your phone - + Reply in %1… - + Group texts can't be sent from the PC yet - + Add a caption (optional)… - + Send - + %1 characters · %n text(s) - + Open image attachment - + Voice note - + · Click to play - + · Loading… - + Attachment (open it on the phone) - + Not sent - + Sending… - + Copy text - + Copied - + Copy code %1 - + Image attachment - + Close - + Open externally - + Done @@ -4653,102 +4813,117 @@ What it needs Mirror - + Couldn't start recording. - + Mirror recording - + Mirror screenshot - + Mirroring is turned off for this phone. - + + Recording from %1, saved in Downloads/Nectarlink + + + + Recording from %1, saved in Downloads\Nectarlink - + Saved %1 - + + Saved %1 to Downloads/Nectarlink. + + + + Saved %1 to Downloads\Nectarlink. - + Screenshot copied to clipboard. - + + Screenshot from %1, saved in Downloads/Nectarlink + + + + Screenshot from %1, saved in Downloads\Nectarlink - + Sending %1 files… - + Sending %1… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. - + Show in folder - + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. - + The phone isn't connected. - + The screenshot couldn't be copied to the clipboard. - + The screenshot couldn't be saved. - + This PC can't decode the phone's video. - + This phone's app doesn't share its screen yet. Update NectarLink on the phone. - + Wait for the screen to appear first. @@ -4900,7 +5075,7 @@ What it needs Mp4 - + No video frames were recorded yet. @@ -4994,39 +5169,49 @@ What it needs Notifications - + Code copied - + Copy code - + Dismissed %1 from %2 - + Dismissed %1 notifications - + Dismissed from %1 - + Notification dismissed - - That notification is gone from the phone. - + + Open + + + + + Reply + + + + + That notification is gone from the phone. + @@ -5140,17 +5325,17 @@ What it needs Obs - + OBS isn't reachable on port %1. Turn on its WebSocket server. - + OBS: %1 - + The service on that port isn't OBS. @@ -5321,7 +5506,7 @@ What it needs PcMirror - + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. @@ -5329,17 +5514,17 @@ What it needs PhoneMic - + Tap Start on %1 to use its microphone. - + The microphone is turned off for this PC on %1. - + Update NectarLink on %1 to use its microphone. @@ -5347,57 +5532,57 @@ What it needs PhotoBackup - + Can't write to the backup folder. - + Copying, %1 left - + Looking for new photos… - + Off - + Photos are turned off for this PC on %1. - + The backup folder's drive is full. - + The backup stopped. It tries again in a few minutes. - + Up to date - + Up to date, %1 backed up - + Update NectarLink on %1 to back up its photos. - + Waiting for %1 @@ -5405,67 +5590,67 @@ What it needs Photos - + Copied to clipboard. - + From %1 - + New photo - + New screenshot - + No recent photos found on the phone. - + Opening latest photo… - + Saved %1 items to %2. - + Saved %1 to %2. - + That photo isn't on the phone anymore. - + The phone doesn't share photos or files with this PC. - + The phone isn't connected right now. - + The photo couldn't be copied to the clipboard. - + The photo couldn't be copied. It's in Downloads\Nectarlink. @@ -5474,7 +5659,7 @@ What it needs PhotosPage - + All photos @@ -5485,259 +5670,259 @@ What it needs - + Today - + Yesterday - + %1 B - + %1 KB - + %1 MB - + %1 GB - + %1 TB - + Choose default folder for saved photos - + Save to folder - + Photos - - + + %1 isn't connected - + Pair your phone to browse photos and albums. - - + + Connect %1 to view photos. - + Albums - + Refresh - + %1+ items - + 1 item - + %1 items - + Cancel - + Photo - + Take photo - + Scan - + Scan document - + Select - + Cancel selection - + %1 selected - + Select items - + Click items to select - + Select all - - + + Copy - + Open - + Save (%1) - - + + Save - - + + Save to folder… - + Photos are off for %1 - + Allow photos on %1 - + Couldn't load photos - + Enable Photos in Settings. - + Grant photos permission on %1. - + Unable to load photos. - + Try again - + No photos or videos - + Back to gallery - - + + Play video - + Open in Photos - + Loading full resolution… - + Previous photo - + Next photo - + %1 - + Select photo @@ -5745,32 +5930,32 @@ What it needs PlayOnPhone - + Connect your phone to play on it. - + Media is turned off for %1. - + Opening in %1 on %2. Tap the notification there. - + Sent to %1. Tap the notification there. - + Tap the notification on %1 to watch. - + Update NectarLink on %1 to play on it. @@ -5778,7 +5963,7 @@ What it needs Prefs - + Connected to OBS. @@ -5786,7 +5971,7 @@ What it needs Recordings - + Recording from %1 saved @@ -5802,57 +5987,57 @@ What it needs SendTo - + Copied to your phone's clipboard. - + Couldn't reach %1. - + Couldn't send %1 - + Couldn't send to %1 - + Couldn't share with %1 - + File failed integrity check; partial file kept to resume. - + Send to %1 with NectarLink - + Sending to %1… - + Sent to %1 - + The transfer didn't go through. - + Waiting for %1… @@ -5901,7 +6086,7 @@ What it needs - + Notifications @@ -6021,1287 +6206,1334 @@ What it needs - - + + %1 B - + %1 KB - + %1 MB - + %1 GB - + %1 TB - + Messages - - + + Calls - + Contacts - + Clipboard - + Files - - + + Voice recordings - + Media controls - + Photos - + Phone controls - + Lock, sleep & wake PC - + Screen mirroring - + Share PC screen to phone - + Webcam - + File Explorer integration - Remote mouse, keyboard & Deck + File manager integration - Run Deck shell commands + Remote mouse, keyboard & Deck + Run Deck shell commands + + + + Remote PC file access - + Allow this phone to view this PC's screen. - + Only enable for a phone you trust. - + Calls, messages & notifications - + Files, photos & clipboard - + Screen, camera & control - + Back up photos to - + Save recordings in - + Pair a new device - + Theme - + Hive - + Bloom - + Glass - + Graphite - + Mode - - + + System - + Light - + Dark - + Color - + Wallpaper - + AMOLED - - - + + + Pure black (AMOLED) - - + + Media artwork - - + + Animated comb - + + Still while animations are off in system settings. + + + + Still while animations are off in Windows. - + Still while energy saver is on. - + The honeycomb breathes and pulses. - + Pitch-black #000000 surfaces. - - + + Mica backdrop - + Tint window with desktop wallpaper. - - + + Language - + + NectarLink follows the system unless you pick one. + + + + NectarLink follows Windows unless you pick one. - + More - - + + Start with Windows - + + + Start at login + + + + Launch in the tray when you sign in. - - + + Stay in system tray - + Keep running when the window is closed. - - + + Nectar Island - + Top-screen pill for calls, transfers & media. - - + + Battery alerts - + Notify when phone battery is low or full. - + Island triggers - + Choose when the top pill appears. - + Transfers - + Media - + Alerts - - + + Sync clipboard to phone - + Share copied text and images with your phone. - - + + Clipboard follows control - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. - - + + Copy one-time codes - + Auto-copy verification codes from texts. - - + + Copy received files - + Ready to Ctrl+V incoming files anywhere. - - + + Suggest clipboard actions - + Open links, maps, numbers & emails from phone clips. - + No phones paired yet - + Pair your phone to get started. - + Pair a phone - + Connected - + Not connected - - + + Unpair - + Join this phone's hotspot - + When Instant Hotspot turns it on, this PC joins %1. - + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. - + Choose network - - - - - - + + + + + + Open - - + + Stream PC audio to phone - + Listen to PC audio through your phone. - - + + Pause media during calls - + Auto-pause PC media when phone rings. - - + + Mute this PC while a phone watches - + With Play on phone, the phone still gets the sound. - + Phone as microphone - + Live from %1, playing into %2. - + Tap Start on %1. - + Plays into %1, so apps can pick its other end as a microphone. - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. - + Output - + Start - + Stop - - + + Microphone volume - - + + %1% - + Virtual camera add-on - + Registered for Zoom, Teams & Meet. - + One-time setup (admin required) for Zoom, Teams & Meet. - - + + Ready - - - + + + Remove - + Set up - + Phone - + Camera - + Back - + Front - - + + Mirror image - + Resolution - + Preview · %1 - + Pair a phone to use its camera as a webcam - + Stop webcam - + Start webcam - - + + Follow phone Do not disturb - + Mute pop-ups while phone is on DND. - - + + Forward PC notifications - + Send PC alerts to phone while away. - + Quiet hours - + Configure automatic DND schedule in Windows. - + Set a schedule - + Show - + No pop-ups - + Hide - - + + File Explorer Send to - + + + File manager Send to + + + + Send files from right-click menu. - - + + Your PC in your pocket - + Share Downloads, Documents, Desktop & Pictures. - - + + LocalSend on local network - + Share with nearby LocalSend devices without pairing. - - + + Open files shared from the phone - + Opens a shared photo or document as soon as it arrives. - - - - - + + + + + Reset - - + + Choose folder - + Photo backup - + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. - + Back up photos from %1 - + Backup folder - + Include videos - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. - + Back up now - + Synced folders - + One-way or two-way folder sync between phone and PC. - + Sync now - + Add DCIM → Pictures - + Two-way sync · conflict copies kept - + One-way · phone to PC - + Command palette - + Open from any app. - + Command palette shortcut - + File shelf - + Toggle screen-edge shelf. - + Shelf shortcut - + Take photo - + Capture & paste photo. - + Take photo shortcut - + Scan document - + Scan & paste document. - + Scan document shortcut - + Trigger-action rules - + Run actions when your phone connects, disconnects, or rings. - + Lock PC on disconnect - + Pause media on ring - + OBS Studio - + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. - + Port - + OBS WebSocket port - + Password - + OBS WebSocket password - + Saved - + None - + Save - + Test connection - + Remove password - - + + Clipboard history - + %1 clips · %2 · kept 7 days - - - - - - + + + + + + Clear - - + + Notification history - + %1 alerts · %2 · kept 24h - + Cross-device timeline - + 1 item stored (max 5,000). - + %1 items stored (max 5,000). - + 30d - + 90d - + 1y - + All - + Cached messages - + 1 chat - + %1 chats - + 1 message - + %1 messages - + %1 · %2 · %3 · kept 90 days - + Photo thumbnails - + %1 cached · %2 · kept 30 days - + Transfer history - + %1 records · kept with timeline - + Clear all local history & caches - + Wipes local history and caches; keeps paired devices and settings. - + Clear everything… - + Connection Doctor - + Diagnose and fix firewall, network, or VPN issues. - + Check - + Connect over mobile data - + Stay connected over LTE/5G or relay when away from Wi-Fi. - + Wake from your phone - + No active network adapter found. - + %1 · Wake on Magic Packet ready. - + %1 · Enable Wake on Magic Packet in Device Manager. - + Off in Windows - + + Off in the adapter's settings + + + + Check settings - + No adapter - - + + Local automation API - + Listening on 127.0.0.1:%1 · Token in %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. - + NectarLink %1 - + This PC: %1 - + Update download - + Update to %1 - - - + + + Check for updates - + Open logs - + Looks for a new version on GitHub once a day. - - + + Install updates automatically - + When nobody is using the PC and nothing is in progress. - + Setup walkthrough - + Re-open the setup guide. - + Features guide - + What each feature needs and where to find it. - + Phone's hotspot network - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. - + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. - + Don't join - - - + + + Close - + Play the phone's microphone into - + Unpair %1? - + Both devices forget each other. You can pair them again any time. - - + + Cancel - + Clear all local history & caches? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. - + Clear everything @@ -7309,25 +7541,35 @@ What it needs Setup - + Couldn't start setup: %1 - + + Couldn't uninstall NectarLink: %1 + + + + Setup is taking too long. Try again after restarting the PC. - + Setup stopped with code %1. - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. + + + Uninstall stopped with code %1. + + SetupWindow @@ -7529,164 +7771,164 @@ What it needs - + %1 B - + %1 KB - + %1 MB - + %1 GB - + Shelf - + No phone connected - + Refresh Shelf - + Close Shelf - + Release to send to %1 - + Sent to %1 - + Send cancelled - + Drop or flick items here to send - + Sending to %1… - + Undo - + Latest photo - + No recent photo - + Latest screenshot - + No recent screenshot - + Copy - + Open - + Current clip - + Send clip to phone - - + + Clip - + Copy clip - - + + Clipboard is empty - + Current clip: %1 - + Recent received files - + No received files yet - + Send to phone - + Open file - + Show in folder - + Flick any card up to send to phone, drag out to use, or drop files in @@ -7694,147 +7936,147 @@ What it needs Timeline - + App window #%1 - + Clip (removed from clipboard history) - + Copied image - + Copied link to clipboard. - + Copied to clipboard. - + From %1 - + In progress - + No phone is paired yet. - + Opened from %1 - + Opened on %1 - + Phone microphone - + Phone webcam - + Recorded on %1 - + Saved from %1 - + Screen mirroring - + Sending to %1… - + Sent clip to %1. - + Sent to %1 - + Session - + That clip is no longer in clipboard history. - + That entry couldn't be removed. Try again. - + That file was moved or deleted. - + That isn't a web link. - + That photo was moved or deleted. - + The Timeline couldn't be cleared. Try again. - + The photo couldn't be copied to the clipboard. - + Voice recording - + With %1 - + device @@ -7879,8 +8121,8 @@ What it needs - - + + All devices @@ -7895,92 +8137,92 @@ What it needs - + Search timeline - + Search timeline… - + Clear search - + Clear all - + Device filter: %1 - + Clear timeline history? Saved files on disk are kept. - + Cancel - + Clear timeline - + No matching items - + No recent activity - + Clear the search or filter. - + Shared files, clips, links, and photos appear here. - + Open - + Show in folder - + Copy - + Send again - + Remove from timeline - + Load more @@ -8126,37 +8368,37 @@ What it needs Transfers - + Accept in NectarLink to save to Downloads\Nectarlink - + Files are turned off for that device. - + From %1, opened from Downloads\Nectarlink - + From %1, saved in Downloads\Nectarlink - + Nearby device - + That's too many files to send at once. - + There's nothing to send in there. @@ -8164,67 +8406,67 @@ What it needs Updater - + Couldn't check for updates. Check your internet connection. - + NectarLink %1 is available - + NectarLink %1 is available. - + NectarLink is still on the version you had. Try again from Settings. - + NectarLink is up to date. - + NectarLink is updated to %1 - + The download was damaged, so it wasn't installed. Try again. - + The update couldn't be downloaded. Check your internet connection and try again. - + The update couldn't be saved. Check that there's free space and try again. - + The update couldn't be started. Try again. - + The update didn't install - + Update now, or later from Settings. - + Your phone reconnects on its own. @@ -8232,191 +8474,202 @@ What it needs WalkthroughPage - + Skip setup - + Notifications and Calls - + Clipboard and Send files - + Mirroring, Camera, and Audio - + Direct local QUIC link - + End-to-end encrypted over QUIC - + Hardware-backed device keys - + Local network by default - + Option 1: Download the Android APK (Android 8.0+) - + Open the latest GitHub Release on your phone or copy the release link. - + Open release page - + Option 2: Install over USB with ADB - + Paired with %1. You can continue to the next step. - - + + your phone - - + + On by default - - + + Off until you turn them on - + Change these per phone on Home after pairing. - + Sharing settings for %1 - + Notifications - + Clipboard - + Send files - + Now playing - + Messages - + Calls - + Screen mirroring - + Remote input - + Share PC folders - + Windows notifications - + + Desktop notifications + + + + Windows Settings - + Recheck - - + + Start with Windows + + Start at login + + + + Keep NectarLink ready in the system tray when you sign in. - + Firewall and network check - + Command Palette (Ctrl+K) - + Features guide - + Back - + Open features guide @@ -8424,12 +8677,12 @@ What it needs Webcam - + Starting camera on %1… - + Streaming %1 × %2 · %3 fps from %4 @@ -8460,17 +8713,17 @@ What it needs Widget - + No phone connected - + No recent clipboard items - + Not available diff --git a/desktop/app/qml/MainWindow.qml b/desktop/app/qml/MainWindow.qml index 276f41c3..1bad044b 100644 --- a/desktop/app/qml/MainWindow.qml +++ b/desktop/app/qml/MainWindow.qml @@ -36,10 +36,8 @@ NativeWindow { if (targetId.indexOf("__grab__:") === 0) { const outPath = targetId.substring("__grab__:".length) AppController.clearHighlight() - if (window.contentItem && outPath.length > 0) { - window.contentItem.grabToImage(function(result) { - result.saveToFile(outPath) - }) + if (outPath.length > 0) { + window.grabToFile(outPath) } return } diff --git a/desktop/app/qml/components/CaptionButtons.qml b/desktop/app/qml/components/CaptionButtons.qml index 25f677b9..3f6c6fb6 100644 --- a/desktop/app/qml/components/CaptionButtons.qml +++ b/desktop/app/qml/components/CaptionButtons.qml @@ -12,7 +12,7 @@ Row { required property NativeWindow window readonly property bool maximized: window.visibility === Window.Maximized - visible: Qt.platform.os === "windows" + visible: Qt.platform.os !== "osx" width: visible ? implicitWidth : 0 // As tall as the top bar, like Windows 11's tall title bars, so the glyphs diff --git a/desktop/app/qml/components/DoctorSheet.qml b/desktop/app/qml/components/DoctorSheet.qml index cb71e378..e2b1cc81 100644 --- a/desktop/app/qml/components/DoctorSheet.qml +++ b/desktop/app/qml/components/DoctorSheet.qml @@ -10,6 +10,10 @@ Sheet { readonly property var checks: { try { return JSON.parse(AppController.doctorChecks) } catch (e) { return [] } } readonly property bool allWell: checks.length > 0 && checks.every(c => c.outcome === "ok") + // Checks for features that need setup (ids "linux-..."), as opposed to + // what stands between the phone and this PC. + readonly property bool connectionWell: checks.length > 0 + && checks.every(c => c.outcome === "ok" || c.id.startsWith("linux-")) function fixLabel(fix) { switch (fix) { @@ -49,83 +53,95 @@ Sheet { ? qsTr("Checking network and firewall…") : sheet.allWell ? qsTr("Everything looks right.") - : qsTr("Issues blocking your phone from reaching this PC:") + : sheet.connectionWell + ? qsTr("Your phone can reach this PC. A few features need a step from you:") + : qsTr("Issues blocking your phone from reaching this PC:") } - Column { + Flickable { width: parent.width - spacing: 2 - opacity: AppController.doctorBusy ? 0.55 : 1 - Behavior on opacity { NumberAnimation { duration: Theme.fadeNormal } } - Repeater { - model: sheet.checks - delegate: Rectangle { - id: row - required property var modelData - readonly property bool hasFix: row.modelData.fix !== undefined && row.modelData.fix !== "" - width: parent.width - height: Math.max(badge.height, words.height, actionsRow.height) + 20 - radius: Theme.radiusMd - color: "transparent" + height: Math.min(checksColumn.height, 360) + contentWidth: width + contentHeight: checksColumn.height + clip: true + boundsBehavior: Flickable.StopAtBounds - Rectangle { - id: badge - x: 8 - anchors.verticalCenter: parent.verticalCenter - width: 28; height: 28 - radius: 14 - color: row.modelData.outcome === "ok" ? Theme.secondaryContainer - : row.modelData.outcome === "warn" ? Qt.alpha(Theme.warning, 0.18) - : Qt.alpha(Theme.error, 0.16) - Icon { - anchors.centerIn: parent - width: 16; height: 16 - path: row.modelData.outcome === "ok" ? Icons.check : Icons.warning - color: row.modelData.outcome === "ok" ? Theme.secondaryContainerContent - : row.modelData.outcome === "warn" ? Theme.warning : Theme.error + Column { + id: checksColumn + width: parent.width + spacing: 2 + opacity: AppController.doctorBusy ? 0.55 : 1 + Behavior on opacity { NumberAnimation { duration: Theme.fadeNormal } } + Repeater { + model: sheet.checks + delegate: Rectangle { + id: row + required property var modelData + readonly property bool hasFix: row.modelData.fix !== undefined && row.modelData.fix !== "" + width: parent.width + height: Math.max(badge.height, words.height, actionsRow.height) + 20 + radius: Theme.radiusMd + color: "transparent" + + Rectangle { + id: badge + x: 8 + anchors.verticalCenter: parent.verticalCenter + width: 28; height: 28 + radius: 14 + color: row.modelData.outcome === "ok" ? Theme.secondaryContainer + : row.modelData.outcome === "warn" ? Qt.alpha(Theme.warning, 0.18) + : Qt.alpha(Theme.error, 0.16) + Icon { + anchors.centerIn: parent + width: 16; height: 16 + path: row.modelData.outcome === "ok" ? Icons.check : Icons.warning + color: row.modelData.outcome === "ok" ? Theme.secondaryContainerContent + : row.modelData.outcome === "warn" ? Theme.warning : Theme.error + } } - } - Column { - id: words - anchors.left: badge.right - anchors.leftMargin: 14 - anchors.right: actionsRow.visible ? actionsRow.left : parent.right - anchors.rightMargin: actionsRow.visible ? 12 : 8 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - Txt { width: parent.width; text: row.modelData.title; role: "title"; size: 14; wrapMode: Text.WordWrap } - Txt { - width: parent.width - visible: row.modelData.detail.length > 0 - text: row.modelData.detail - role: "bodySmall" - muted: true - wrapMode: Text.WordWrap + Column { + id: words + anchors.left: badge.right + anchors.leftMargin: 14 + anchors.right: actionsRow.visible ? actionsRow.left : parent.right + anchors.rightMargin: actionsRow.visible ? 12 : 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Txt { width: parent.width; text: row.modelData.title; role: "title"; size: 14; wrapMode: Text.WordWrap } + Txt { + width: parent.width + visible: row.modelData.detail.length > 0 + text: row.modelData.detail + role: "bodySmall" + muted: true + wrapMode: Text.WordWrap + } } - } - Row { - id: actionsRow - anchors.right: parent.right - anchors.rightMargin: 6 - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - visible: row.hasFix + Row { + id: actionsRow + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + visible: row.hasFix - Button { - visible: row.modelData.fix === "firewall" - enabled: !AppController.doctorBusy - variant: "tonal" - size: "sm" - text: qsTr("Firewall settings") - onClicked: AppController.doctorFix("firewall-settings") - } - Button { - id: fixButton - enabled: !AppController.doctorBusy - variant: row.modelData.outcome === "fail" ? "fill" : "tonal" - size: "sm" - text: sheet.fixLabel(row.modelData.fix) - onClicked: AppController.doctorFix(row.modelData.fix) + Button { + visible: row.modelData.fix === "firewall" + enabled: !AppController.doctorBusy + variant: "tonal" + size: "sm" + text: qsTr("Firewall settings") + onClicked: AppController.doctorFix("firewall-settings") + } + Button { + id: fixButton + enabled: !AppController.doctorBusy + variant: row.modelData.outcome === "fail" ? "fill" : "tonal" + size: "sm" + text: sheet.fixLabel(row.modelData.fix) + onClicked: AppController.doctorFix(row.modelData.fix) + } } } } diff --git a/desktop/app/qml/pages/AppsPage.qml b/desktop/app/qml/pages/AppsPage.qml index 0aac3d78..42c33109 100644 --- a/desktop/app/qml/pages/AppsPage.qml +++ b/desktop/app/qml/pages/AppsPage.qml @@ -87,7 +87,9 @@ Item { muted: true text: page.deviceId.length === 0 ? qsTr("Pair your phone to open its apps in windows on this PC.") - : (page.feature.reason || qsTr("Set up Wireless debugging in NectarLink on the phone to open its apps here.")) + : page.feature.reason === "notOnThisDevice" + ? qsTr("Opening apps in windows isn't supported on this phone.") + : (page.feature.reason || qsTr("Set up Wireless debugging in NectarLink on the phone to open its apps here.")) } LockChip { anchors.horizontalCenter: parent.horizontalCenter diff --git a/desktop/app/qml/pages/DeckPage.qml b/desktop/app/qml/pages/DeckPage.qml index 2757d297..0567c3ba 100644 --- a/desktop/app/qml/pages/DeckPage.qml +++ b/desktop/app/qml/pages/DeckPage.qml @@ -98,9 +98,9 @@ Item { { id: "volume_up", group: qsTr("Audio"), label: qsTr("Volume up") }, { id: "volume_mute", group: qsTr("Audio"), label: qsTr("Mute speaker") }, { id: "mic_mute", group: qsTr("Audio"), label: qsTr("Mute microphone") }, - { id: "show_desktop", group: qsTr("System"), label: qsTr("Show desktop (Win+D)") }, + { id: "show_desktop", group: qsTr("System"), label: Qt.platform.os === "linux" ? qsTr("Show desktop (Super+D)") : qsTr("Show desktop (Win+D)") }, { id: "switch_window", group: qsTr("System"), label: qsTr("Switch window (Alt+Tab)") }, - { id: "screenshot", group: qsTr("System"), label: qsTr("Screenshot (Win+Shift+S)") }, + { id: "screenshot", group: qsTr("System"), label: Qt.platform.os === "linux" ? qsTr("Screenshot") : qsTr("Screenshot (Win+Shift+S)") }, { id: "lock_pc", group: qsTr("System"), label: qsTr("Lock PC") }, { id: "shortcut", group: qsTr("Custom"), label: qsTr("Keyboard shortcut") }, { id: "open_url", group: qsTr("Custom"), label: qsTr("Open website") }, @@ -746,7 +746,7 @@ Item { Chip { interactive: true selected: editorSheet.modWin - text: qsTr("Win") + text: Qt.platform.os === "linux" ? qsTr("Super") : qsTr("Win") onClicked: editorSheet.modWin = !editorSheet.modWin } } diff --git a/desktop/app/qml/pages/HomePage.qml b/desktop/app/qml/pages/HomePage.qml index e7d0aa5c..4952ec1d 100644 --- a/desktop/app/qml/pages/HomePage.qml +++ b/desktop/app/qml/pages/HomePage.qml @@ -1730,10 +1730,12 @@ Item { Icon { anchors.verticalCenter: parent.verticalCenter; path: Icons.bell; color: Theme.surfaceContentVariant } Txt { anchors.verticalCenter: parent.verticalCenter - width: parent.width - 32 - turnOn.width - 24 + width: parent.width - 32 - (turnOn.visible ? turnOn.width + 24 : 0) role: "bodySmall" wrapMode: Text.WordWrap - text: qsTr("Windows notifications are off.") + text: Qt.platform.os === "linux" + ? qsTr("Desktop notifications are off. Turn them on in your system's notification settings.") + : qsTr("Windows notifications are off.") } Button { id: turnOn @@ -1741,6 +1743,7 @@ Item { variant: "tonal" size: "sm" text: qsTr("Turn on") + visible: Qt.platform.os === "windows" onClicked: Qt.openUrlExternally("ms-settings:notifications") } } @@ -2548,7 +2551,9 @@ Item { ? qsTr("Hidden on this PC.") : appSheet.rule === "quiet" ? qsTr("Shown in feed without pop-ups.") - : qsTr("Shown in feed and as Windows pop-ups.") + : Qt.platform.os === "linux" + ? qsTr("Shown in feed and as desktop pop-ups.") + : qsTr("Shown in feed and as Windows pop-ups.") } Row { anchors.right: parent.right diff --git a/desktop/app/qml/pages/SettingsPage.qml b/desktop/app/qml/pages/SettingsPage.qml index 668af9d4..4c70a3a4 100644 --- a/desktop/app/qml/pages/SettingsPage.qml +++ b/desktop/app/qml/pages/SettingsPage.qml @@ -83,7 +83,7 @@ Item { if (event.modifiers & Qt.ControlModifier) mods.push("Ctrl") if (event.modifiers & Qt.AltModifier) mods.push("Alt") if (event.modifiers & Qt.ShiftModifier) mods.push("Shift") - if (event.modifiers & Qt.MetaModifier) mods.push("Win") + if (event.modifiers & Qt.MetaModifier) mods.push(Qt.platform.os === "linux" ? "Super" : "Win") if (mods.length === 0) return "" let keyName = "" @@ -167,7 +167,7 @@ Item { mirroring: qsTr("Screen mirroring"), pc_mirror: qsTr("Share PC screen to phone"), webcam: qsTr("Webcam"), - storage: qsTr("File Explorer integration"), + storage: Qt.platform.os === "linux" ? qsTr("File manager integration") : qsTr("File Explorer integration"), remote_input: qsTr("Remote mouse, keyboard & Deck"), commands: qsTr("Run Deck shell commands"), remote_files: qsTr("Remote PC file access") @@ -800,7 +800,9 @@ Item { iconPath: Icons.sparkle title: qsTr("Animated comb") description: AppController.reduceMotion - ? qsTr("Still while animations are off in Windows.") + ? (Qt.platform.os === "linux" + ? qsTr("Still while animations are off in system settings.") + : qsTr("Still while animations are off in Windows.")) : (AppController.powerSaver ? qsTr("Still while energy saver is on.") : qsTr("The honeycomb breathes and pulses.")) @@ -814,16 +816,19 @@ Item { // Side-by-side when Graphite Dark shows Pure Black + Mica backdrop Item { id: appearanceBottomPair - visible: !Theme.hive - width: parent.width readonly property bool showGraphiteAmoled: Theme.graphite && Theme.dark - readonly property bool splitCols: showGraphiteAmoled && width >= 460 + readonly property bool showMica: Qt.platform.os === "windows" + visible: !Theme.hive && (showMica || showGraphiteAmoled) + width: parent.width + readonly property bool splitCols: showGraphiteAmoled && showMica && width >= 460 readonly property real colWidth: splitCols ? Math.floor((width - 37) / 2) : width implicitHeight: !showGraphiteAmoled - ? micaRow.implicitHeight - : (splitCols - ? Math.max(graphiteAmoledRow.implicitHeight, micaRow.implicitHeight) - : (graphiteAmoledRow.implicitHeight + 1 + micaRow.implicitHeight)) + ? (showMica ? micaRow.implicitHeight : 0) + : (!showMica + ? graphiteAmoledRow.implicitHeight + : (splitCols + ? Math.max(graphiteAmoledRow.implicitHeight, micaRow.implicitHeight) + : (graphiteAmoledRow.implicitHeight + 1 + micaRow.implicitHeight))) ListRow { id: graphiteAmoledRow @@ -851,13 +856,14 @@ Item { } Divider { - visible: appearanceBottomPair.showGraphiteAmoled && !appearanceBottomPair.splitCols + visible: appearanceBottomPair.showGraphiteAmoled && appearanceBottomPair.showMica && !appearanceBottomPair.splitCols y: graphiteAmoledRow.implicitHeight width: parent.width } ListRow { id: micaRow + visible: appearanceBottomPair.showMica x: appearanceBottomPair.splitCols ? (parent.width - width) : 0 y: !appearanceBottomPair.showGraphiteAmoled ? 0 @@ -898,7 +904,9 @@ Item { width: parent.width iconPath: Icons.globe title: qsTr("Language") - description: qsTr("NectarLink follows Windows unless you pick one.") + description: Qt.platform.os === "linux" + ? qsTr("NectarLink follows the system unless you pick one.") + : qsTr("NectarLink follows Windows unless you pick one.") Segmented { readonly property var other: page.languages.find( (l) => l.code === Preferences.language && l.code !== "en") @@ -936,10 +944,10 @@ Item { y: startupPairRow.splitCols ? Math.round((parent.height - height) / 2) : 0 width: startupPairRow.colWidth iconPath: Icons.power - title: qsTr("Start with Windows") + title: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") description: qsTr("Launch in the tray when you sign in.") Toggle { - label: qsTr("Start with Windows") + label: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") checked: Preferences.startWithWindows onToggled: (on) => Preferences.startWithWindows = on } @@ -1390,7 +1398,9 @@ Item { title: qsTr("Join this phone's hotspot") description: hotspotRow.network.length > 0 ? qsTr("When Instant Hotspot turns it on, this PC joins %1.").arg(hotspotRow.network) - : qsTr("Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here.") + : (Qt.platform.os === "linux" + ? qsTr("Join the phone's hotspot once from your system Wi-Fi list, then choose it here.") + : qsTr("Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here.")) Button { variant: "tonal" size: "sm" @@ -1960,9 +1970,10 @@ Item { } } - Divider { width: parent.width } + Divider { width: parent.width; visible: Qt.platform.os === "windows" } ListRow { width: parent.width + visible: Qt.platform.os === "windows" iconPath: Icons.moon title: qsTr("Quiet hours") description: qsTr("Configure automatic DND schedule in Windows.") @@ -2056,10 +2067,10 @@ Item { y: filesSharePairRow.splitCols ? Math.round((parent.height - height) / 2) : 0 width: filesSharePairRow.colWidth iconPath: Icons.send - title: qsTr("File Explorer Send to") + title: Qt.platform.os === "linux" ? qsTr("File manager Send to") : qsTr("File Explorer Send to") description: qsTr("Send files from right-click menu.") Toggle { - label: qsTr("File Explorer Send to") + label: Qt.platform.os === "linux" ? qsTr("File manager Send to") : qsTr("File Explorer Send to") checked: Preferences.sendToMenu onToggled: (on) => Preferences.sendToMenu = on } @@ -3142,7 +3153,8 @@ Item { role: "bodySmall" muted: true text: AppController.wakeState === "enabled" ? qsTr("Ready") - : AppController.wakeState === "disabled" ? qsTr("Off in Windows") + : AppController.wakeState === "disabled" + ? (Qt.platform.os === "linux" ? qsTr("Off in the adapter's settings") : qsTr("Off in Windows")) : AppController.wakeState === "unknown" ? qsTr("Check settings") : qsTr("No adapter") } @@ -3361,9 +3373,13 @@ Item { wrapMode: Text.WordWrap role: "body" muted: true - text: hotspotSheet.networks.length > 0 - ? qsTr("Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them.") - : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first.") + text: Qt.platform.os === "linux" + ? (hotspotSheet.networks.length > 0 + ? qsTr("Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them.") + : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first.")) + : (hotspotSheet.networks.length > 0 + ? qsTr("Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them.") + : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first.")) } // A PC can have saved dozens of networks: the list scrolls. ListView { diff --git a/desktop/app/qml/pages/WalkthroughPage.qml b/desktop/app/qml/pages/WalkthroughPage.qml index dadc123f..32d25f56 100644 --- a/desktop/app/qml/pages/WalkthroughPage.qml +++ b/desktop/app/qml/pages/WalkthroughPage.qml @@ -189,7 +189,9 @@ FocusScope { if (AppController.doctorBusy) return "Checking firewall and local network..." if (parsedDoctorChecks.length === 0) - return "Run a quick check to verify Windows Firewall and local discovery." + return Qt.platform.os === "linux" + ? "Run a quick check to verify your firewall and local discovery." + : "Run a quick check to verify Windows Firewall and local discovery." let problems = 0 for (let i = 0; i < parsedDoctorChecks.length; i++) { if (parsedDoctorChecks[i].status !== "ok") @@ -1461,15 +1463,19 @@ FocusScope { Txt { Layout.fillWidth: true - text: qsTr("Windows notifications") + text: Qt.platform.os === "linux" ? qsTr("Desktop notifications") : qsTr("Windows notifications") role: "title" } Txt { Layout.fillWidth: true text: AppController.toastsEnabled - ? "Enabled in Windows Settings." - : "Notifications are off for NectarLink in Windows Settings." + ? (Qt.platform.os === "linux" + ? "Enabled in desktop notification settings." + : "Enabled in Windows Settings.") + : (Qt.platform.os === "linux" + ? "Notifications are muted or Do Not Disturb is active." + : "Notifications are off for NectarLink in Windows Settings.") role: "caption" color: Theme.surfaceContentVariant wrapMode: Text.WordWrap @@ -1483,7 +1489,7 @@ FocusScope { text: qsTr("Windows Settings") variant: "tonal" size: "sm" - visible: !AppController.toastsEnabled + visible: !AppController.toastsEnabled && Qt.platform.os === "windows" Accessible.name: "Open Windows notification settings" onClicked: Qt.openUrlExternally("ms-settings:notifications") } @@ -1493,7 +1499,7 @@ FocusScope { iconPath: Icons.refresh variant: "text" size: "sm" - Accessible.name: "Recheck Windows notification status" + Accessible.name: "Recheck notification status" onClicked: AppController.refreshToastsEnabled() } } @@ -1539,7 +1545,7 @@ FocusScope { Txt { Layout.fillWidth: true - text: qsTr("Start with Windows") + text: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") role: "title" } @@ -1553,7 +1559,7 @@ FocusScope { } Toggle { - label: qsTr("Start with Windows") + label: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") checked: Preferences.startWithWindows onToggled: next => Preferences.startWithWindows = next } diff --git a/desktop/app/src/bridge/app.rs b/desktop/app/src/bridge/app.rs index bc299ae7..09c35fee 100644 --- a/desktop/app/src/bridge/app.rs +++ b/desktop/app/src/bridge/app.rs @@ -484,6 +484,7 @@ pub struct AppControllerRust { /// reach it. static CONTROLLER: OnceLock>>> = OnceLock::new(); static SHELF_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static QUIT_REQUESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[derive(Debug, Clone)] enum ToastAction { @@ -599,11 +600,18 @@ fn controller() -> Option> { /// Asks the app to quit (from any thread), as Quit in the tray does. pub fn request_quit() { + QUIT_REQUESTED.store(true, Ordering::Release); if let Some(qt) = controller() { let _ = qt.queue(|object| object.quit_requested()); } } +/// Whether [`request_quit`] has already been called (for example by a signal +/// arriving while the QML engine is still loading). +pub fn is_quit_requested() -> bool { + QUIT_REQUESTED.load(Ordering::Acquire) +} + /// Asks the UI to show the main window (from any thread). pub fn request_activation() { if let Some(qt) = controller() { @@ -750,6 +758,9 @@ impl cxx_qt::Initialize for qobject::AppController { self.as_mut().refresh_localsend_state(); let qt = self.qt_thread(); *CONTROLLER.get_or_init(Mutex::default).lock().unwrap_or_else(|e| e.into_inner()) = Some(qt.clone()); + if QUIT_REQUESTED.load(Ordering::Acquire) { + let _ = qt.queue(|object| object.quit_requested()); + } if let Some(task) = PENDING_JUMP_TASK.lock().unwrap_or_else(|e| e.into_inner()).take() { dispatch_jump_task(&qt, task); } @@ -2222,7 +2233,7 @@ fn load_guide_doc_content(raw_key: &str) -> (String, String) { tr!("Guide", "Install and first-time setup"), tr!( "Guide", - "1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer).\n\ + "1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer).\n\ 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n\ 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n\ 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n\ diff --git a/desktop/app/src/bridge/native.rs b/desktop/app/src/bridge/native.rs index 7903c20f..3ef4e1ef 100644 --- a/desktop/app/src/bridge/native.rs +++ b/desktop/app/src/bridge/native.rs @@ -6,9 +6,14 @@ pub mod ffi { unsafe extern "C++" { include!("app_helpers.h"); include!("video_view.h"); + include!("cxx-qt-lib/qguiapplication.h"); include!("cxx-qt-lib/qqmlapplicationengine.h"); + type QGuiApplication = cxx_qt_lib::QGuiApplication; type QQmlApplicationEngine = cxx_qt_lib::QQmlApplicationEngine; + #[allow(dead_code)] + fn create_qt_app() -> UniquePtr; + /// The latest picture of a video stream (32-bit BGRX rows), for /// `VideoView`s showing it. Any thread. fn video_frame(stream: &str, width: u32, height: u32, bgrx: &[u8]); @@ -69,5 +74,77 @@ pub mod ffi { #[allow(dead_code)] fn mac_free_space(path: &str, ok: &mut bool) -> u64; + + #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] + fn linux_tray_setup( + icon_path: &str, + tooltip: &str, + open_label: &str, + command_palette_label: &str, + shelf_label: &str, + take_photo_label: &str, + scan_document_label: &str, + find_phone_label: &str, + open_link_label: &str, + quit_label: &str, + ); + + #[allow(dead_code)] + fn linux_tray_set_tooltip(tooltip: &str); + + #[allow(dead_code)] + fn linux_tray_set_find_phone_enabled(enabled: bool); + + #[allow(dead_code)] + fn linux_tray_teardown(); + + #[allow(dead_code)] + fn linux_tray_wait_action(timeout_ms: u32) -> String; + + #[allow(dead_code)] + fn linux_tray_is_available() -> bool; + + #[allow(dead_code)] + fn linux_work_area(x: &mut i32, y: &mut i32, w: &mut i32, h: &mut i32) -> bool; + + #[allow(dead_code)] + fn linux_screen_geometry(x: &mut i32, y: &mut i32, w: &mut i32, h: &mut i32) -> bool; + + #[allow(dead_code)] + fn linux_set_activation_token(token: &str); + + #[allow(dead_code)] + fn linux_clipboard_sequence() -> u64; + + #[allow(dead_code)] + fn linux_clipboard_wait_change(last_seq: u64, timeout_ms: u32) -> u64; + + #[allow(dead_code)] + fn linux_clipboard_notify_external(); + + #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] + fn linux_clipboard_read( + check_privacy: bool, + is_private: &mut bool, + has_files: &mut bool, + text: &mut String, + html: &mut String, + rtf: &mut String, + png_bytes: &mut Vec, + ) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_read_files() -> Vec; + + #[allow(dead_code)] + fn linux_clipboard_write_text(text: &str, html: &str, rtf: &str, is_private: bool) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_write_image_png(png: &[u8]) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_write_files(paths: &[String]) -> bool; } } diff --git a/desktop/app/src/bridge/photos.rs b/desktop/app/src/bridge/photos.rs index ad9a0a37..ba3aa046 100644 --- a/desktop/app/src/bridge/photos.rs +++ b/desktop/app/src/bridge/photos.rs @@ -217,9 +217,13 @@ fn folder_from_url(url: &QString) -> Option { impl cxx_qt::Initialize for qobject::Photos { fn initialize(mut self: Pin<&mut Self>) { + #[cfg(windows)] + const DEFAULT_SAVE_FOLDER: &str = r"Downloads\Nectarlink"; + #[cfg(not(windows))] + const DEFAULT_SAVE_FOLDER: &str = "Downloads/Nectarlink"; self.as_mut().set_status(QString::from("idle")); self.as_mut().set_albums(QString::from("[]")); - self.as_mut().set_save_folder(QString::from(r"Downloads\Nectarlink")); + self.as_mut().set_save_folder(QString::from(DEFAULT_SAVE_FOLDER)); super::subscribe(self.qt_thread(), Changes::PHOTOS, Self::update_view); self.update_view(); } diff --git a/desktop/app/src/calls.rs b/desktop/app/src/calls.rs index 69893b44..d7d786ad 100644 --- a/desktop/app/src/calls.rs +++ b/desktop/app/src/calls.rs @@ -58,8 +58,11 @@ fn note_call_ended(device: DeviceId) { let all_done = ongoing_calls(|c| c.remove(&device) && c.is_empty()); if all_done { crate::win::media_sessions::resume_after_call(); - bt_hfp::disconnect_audio(); - refresh_hfp_cache(None); + let _ = std::thread::Builder::new().name("hfp-disconnect".into()).spawn(|| { + bt_hfp::disconnect_audio(); + refresh_hfp_cache(None); + page_changed(); + }); } } @@ -604,7 +607,7 @@ pub fn open_device(device: DeviceId) { } same }); - refresh_hfp_cache(Some(device)); + refresh_hfp_cache_async(Some(device)); if !same { page_changed(); } @@ -630,7 +633,7 @@ pub fn ensure_contacts(device: DeviceId) { pub fn reload() { let dev = page_state(|s| s.device); - refresh_hfp_cache(dev); + refresh_hfp_cache_async(dev); load_call_log(None); load_contacts(); } @@ -901,35 +904,52 @@ fn refresh_hfp_cache(device: Option) -> bt_hfp::HfpStatus { status } +fn refresh_hfp_cache_async(device: Option) { + let target = device.or_else(|| page_state(|s| s.device)); + let _ = std::thread::Builder::new().name("hfp-probe".into()).spawn(move || { + refresh_hfp_cache(target); + page_changed(); + }); +} + /// Returns the current Bluetooth HFP / PC audio status for `device`. pub fn hfp_status(device: Option) -> bt_hfp::HfpStatus { let target = device.or_else(|| page_state(|s| s.device)); - if let Ok(guard) = HFP_CACHE.lock() - && let Some((cached_dev, status)) = guard.as_ref() - && *cached_dev == target - { - return status.clone(); + if let Ok(mut guard) = HFP_CACHE.lock() { + if let Some((cached_dev, status)) = guard.as_ref() + && *cached_dev == target + { + return status.clone(); + } + let placeholder = bt_hfp::HfpStatus::default(); + *guard = Some((target, placeholder.clone())); + drop(guard); + refresh_hfp_cache_async(target); + return placeholder; } - refresh_hfp_cache(target) + bt_hfp::HfpStatus::default() } /// Opens a Bluetooth connection to route call audio through this PC. pub fn connect_pc_audio(device: Option) { let target = device.or_else(|| page_state(|s| s.device)); let name = target.map(phone_name); - match bt_hfp::connect_audio(name.as_deref()) { - Ok(status) => { - if let Ok(mut guard) = HFP_CACHE.lock() { - *guard = Some((target, status)); + let _ = + std::thread::Builder::new().name("hfp-connect".into()).spawn(move || { + match bt_hfp::connect_audio(name.as_deref()) { + Ok(status) => { + if let Ok(mut guard) = HFP_CACHE.lock() { + *guard = Some((target, status)); + } + page_changed(); + } + Err(err) => { + refresh_hfp_cache(target); + page_changed(); + show_message(err); + } } - page_changed(); - } - Err(err) => { - refresh_hfp_cache(target); - page_changed(); - show_message(err); - } - } + }); } /// Opens Windows Bluetooth & devices settings (`ms-settings:bluetooth`). diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index 7984c837..527c6470 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -101,7 +101,7 @@ pub fn start(data_dir: PathBuf, platform: Arc) -> std::io::Result< /// and input injection (`mirror.input`, `input.inject`) only when enabled for at /// least one paired device (or actively sharing). pub fn compute_capabilities( - #[cfg_attr(not(windows), allow(unused_variables))] node: Option<&Node>, + #[cfg_attr(not(any(windows, target_os = "linux")), allow(unused_variables))] node: Option<&Node>, ) -> Vec { let mut caps = vec![ "pc.power".into(), @@ -112,27 +112,40 @@ pub fn compute_capabilities( nectarlink_core::SMS_SHOW.into(), nectarlink_core::TOGGLES_SHOW.into(), ]; - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { caps.push(nectarlink_core::PC_WAKE.into()); caps.push("media.control".into()); - caps.push(nectarlink_core::STORAGE_MOUNT.into()); - caps.push(nectarlink_core::STORAGE_READ.into()); - caps.push(nectarlink_core::STORAGE_WRITE.into()); caps.push(nectarlink_core::MIRROR_VIEW.into()); caps.push(nectarlink_core::MIRROR_LISTEN.into()); caps.push(nectarlink_core::DECK_ACTIONS.into()); caps.push(nectarlink_core::PC_AUDIO.into()); caps.push(nectarlink_core::RECORDER.into()); + #[cfg(windows)] caps.push(nectarlink_core::WEBCAM_VIRTUAL.into()); + #[cfg(target_os = "linux")] + if crate::win::vcam::dll_path().is_some() || crate::win::vcam::is_registered() { + caps.push(nectarlink_core::WEBCAM_VIRTUAL.into()); + } + #[cfg(windows)] caps.push(nectarlink_core::MEDIA_STREAM.into()); + #[cfg(target_os = "linux")] + if crate::win::window_capture::is_available() && crate::pc_mirror::is_capture_available() { + caps.push(nectarlink_core::MEDIA_STREAM.into()); + } caps.push(nectarlink_core::MIC_LISTEN.into()); } #[cfg(windows)] + { + caps.push(nectarlink_core::STORAGE_MOUNT.into()); + caps.push(nectarlink_core::STORAGE_READ.into()); + caps.push(nectarlink_core::STORAGE_WRITE.into()); + } + #[cfg(any(windows, target_os = "linux"))] let mut pc_mirror_enabled = crate::pc_mirror::is_active(); - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] let mut remote_input_enabled = false; - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] if let Some(node) = node && let Ok(devices) = node.paired_devices() { @@ -149,12 +162,16 @@ pub fn compute_capabilities( } } } - #[cfg(windows)] - if pc_mirror_enabled { + #[cfg(any(windows, target_os = "linux"))] + if pc_mirror_enabled && crate::pc_mirror::is_capture_available() { caps.push(nectarlink_core::MIRROR_CAPTURE.into()); } #[cfg(windows)] - if remote_input_enabled { + let input_available = true; + #[cfg(target_os = "linux")] + let input_available = crate::win::input::is_available(); + #[cfg(any(windows, target_os = "linux"))] + if remote_input_enabled && input_available { caps.push(nectarlink_core::MIRROR_INPUT.into()); caps.push(nectarlink_core::INPUT_INJECT.into()); } @@ -179,8 +196,11 @@ async fn run(data_dir: PathBuf, platform: Arc) { let wake = crate::win::wol::query_wake_status(); let mut config = NodeConfig::new(&data_dir, this_device(), env!("CARGO_PKG_VERSION")); config.downloads_dir = Some(downloads_dir()); - // NECTARLINK_NO_ADB=1 keeps a development copy from reaching phones plugged in over USB. - let adb_disabled = std::env::var_os("NECTARLINK_NO_ADB").is_some_and(|v| v == "1"); + // NECTARLINK_NO_ADB=1 keeps a development copy from reaching phones plugged in over USB, + // unless NECTARLINK_ADB_ONLY_SERIAL explicitly restricts ADB to an Android emulator. + let only_emulator = + std::env::var("NECTARLINK_ADB_ONLY_SERIAL").is_ok_and(|s| s.trim().starts_with("emulator-")); + let adb_disabled = !only_emulator && std::env::var_os("NECTARLINK_NO_ADB").is_some_and(|v| v == "1"); config.adb_server = (!adb_disabled).then_some(nectarlink_core::DEFAULT_ADB_ADDR); config.away_mode = crate::settings::Settings::load(&data_dir).away_mode; // This PC's own players, for phones (crate::win::media_sessions). diff --git a/desktop/app/src/deck.rs b/desktop/app/src/deck.rs index a969cafb..42ebaa9a 100644 --- a/desktop/app/src/deck.rs +++ b/desktop/app/src/deck.rs @@ -321,7 +321,14 @@ fn execute_action(action: &DeckAction) -> Result<(), String> { Ok(()) } DeckAction::Screenshot => { - win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + #[cfg(target_os = "linux")] + { + let _ = win::shell::take_screenshot(); + } + #[cfg(not(target_os = "linux"))] + { + win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + } Ok(()) } DeckAction::Shortcut { key, mods } => { @@ -512,7 +519,13 @@ pub fn build_action( }; if !action.is_valid() { return Err(match kind { - deck_kinds::LAUNCH_APP => tr!("Deck", "Application path must end with .exe or .lnk."), + deck_kinds::LAUNCH_APP => { + if cfg!(target_os = "linux") { + tr!("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage).") + } else { + tr!("Deck", "Application path must end with .exe or .lnk.") + } + } deck_kinds::OPEN_URL => tr!("Deck", "Web address must start with http:// or https://."), deck_kinds::SHORTCUT => { tr!("Deck", "Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.).") @@ -617,6 +630,12 @@ mod tests { assert_eq!(app, DeckAction::LaunchApp { path: "C:\\Windows\\System32\\calc.exe".into() }); assert!(build_action("launch_app", "C:\\script.bat", false, false, false, false).is_err()); + let linux_bin = build_action("launch_app", "/usr/bin/firefox", false, false, false, false).unwrap(); + assert_eq!(linux_bin, DeckAction::LaunchApp { path: "/usr/bin/firefox".into() }); + let linux_desktop = + build_action("launch_app", "org.mozilla.firefox.desktop", false, false, false, false).unwrap(); + assert_eq!(linux_desktop, DeckAction::LaunchApp { path: "org.mozilla.firefox.desktop".into() }); + let cmd = build_action("run_command", " echo hello ", false, false, false, false).unwrap(); assert_eq!(cmd, DeckAction::RunCommand { command: "echo hello".into() }); } diff --git a/desktop/app/src/doctor.rs b/desktop/app/src/doctor.rs index 85508aab..7c4f8ef9 100644 --- a/desktop/app/src/doctor.rs +++ b/desktop/app/src/doctor.rs @@ -3,7 +3,7 @@ //! and a button to fix what can be fixed from here (the firewall, the //! network's profile, reconnecting). //! -//! Checks run on a worker thread (they ask Windows a few slow questions) +//! Checks run on a worker thread (they ask the OS a few slow questions) //! and come back as a list for the UI. use crate::i18n::tr; @@ -44,7 +44,7 @@ pub struct DeviceFact { pub dial_failures: u32, } -/// What Windows said, gathered first so the wording can be tested. +/// What the OS said, gathered first so the wording can be tested. #[derive(Debug, Clone, Default)] pub struct Facts { pub local_address: bool, @@ -55,9 +55,15 @@ pub struct Facts { pub firewall: Option, /// Paired devices: link and discovery diagnostics. pub devices: Vec, + /// Optional helper tools missing from `PATH` on Linux. + #[cfg(target_os = "linux")] + pub missing_tools: Vec<&'static str>, + /// Linux-specific system readiness checks: `(id, title, ok, detail, fix)`. + #[cfg(target_os = "linux")] + pub linux_checks: Vec<(String, String, bool, String, Option<&'static str>)>, } -/// Asks Windows and the core (blocking; call on a worker thread). +/// Asks the OS and the core (blocking; call on a worker thread). pub fn gather() -> Facts { let adapters = windows_checks::adapters(); let networks = windows_checks::networks() @@ -81,7 +87,17 @@ pub fn gather() -> Facts { }) .collect() }); - Facts { local_address: adapters.local_address, vpn: adapters.vpn, networks, firewall, devices } + Facts { + local_address: adapters.local_address, + vpn: adapters.vpn, + networks, + firewall, + devices, + #[cfg(target_os = "linux")] + missing_tools: windows_checks::missing_tools(), + #[cfg(target_os = "linux")] + linux_checks: windows_checks::linux_checks(), + } } /// A network's name as people say it. While Windows is still identifying @@ -204,12 +220,23 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { checks.push(check("network", Outcome::Ok, title, "", None)); } if let Some((name, _)) = facts.networks.iter().find(|(_, trusted)| !trusted) { + #[cfg(target_os = "linux")] + let profile_detail = tr!( + "Doctor", + "On public networks the firewall keeps other devices from finding this PC. If it's your home or work \ + network, open network settings and set its firewall zone to Home or Trusted." + ); + #[cfg(not(target_os = "linux"))] + let profile_detail = tr!( + "Doctor", + "On public networks Windows keeps other devices from finding this PC. If it's your home or work \ + network, set it to Private." + ); checks.push(check( "profile", Outcome::Warn, tr!("Doctor", "%1 is set as a public network", name), - tr!("Doctor", "On public networks Windows keeps other devices from finding this PC. If it's your home or work \ - network, set it to Private."), + profile_detail, Some("network-settings"), )); } @@ -218,38 +245,51 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { match facts.firewall { None => {} Some(f) if !f.on => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "The firewall is off for this network"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "Windows Firewall is off for this network"); + checks.push(check("firewall", Outcome::Ok, title, "", None)); + } + Some(f) if f.blocked => { + #[cfg(target_os = "linux")] + let (title, detail) = ( + tr!("Doctor", "The firewall blocks NectarLink"), + tr!( + "Doctor", + "A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect." + ), + ); + #[cfg(not(target_os = "linux"))] + let (title, detail) = ( + tr!("Doctor", "Windows Firewall blocks NectarLink"), + tr!( + "Doctor", + "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect." + ), + ); + checks.push(check("firewall", Outcome::Fail, title, detail, Some("firewall"))); + } + Some(f) if !f.allowed => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "NectarLink isn't allowed through the firewall"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "NectarLink isn't allowed through Windows Firewall"); checks.push(check( "firewall", - Outcome::Ok, - tr!("Doctor", "Windows Firewall is off for this network"), - "", - None, + Outcome::Warn, + title, + tr!("Doctor", "Your phone may not be able to connect. Allow it once here."), + Some("firewall"), )); } - Some(f) if f.blocked => checks.push(check( - "firewall", - Outcome::Fail, - tr!("Doctor", "Windows Firewall blocks NectarLink"), - tr!( - "Doctor", - "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect." - ), - Some("firewall"), - )), - Some(f) if !f.allowed => checks.push(check( - "firewall", - Outcome::Warn, - tr!("Doctor", "NectarLink isn't allowed through Windows Firewall"), - tr!("Doctor", "Your phone may not be able to connect. Allow it once here."), - Some("firewall"), - )), - Some(_) => checks.push(check( - "firewall", - Outcome::Ok, - tr!("Doctor", "Windows Firewall lets your phone connect"), - "", - None, - )), + Some(_) => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "The firewall lets your phone connect"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "Windows Firewall lets your phone connect"); + checks.push(check("firewall", Outcome::Ok, title, "", None)); + } } if facts.vpn { @@ -263,6 +303,31 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { )); } + #[cfg(target_os = "linux")] + if !facts.missing_tools.is_empty() { + let list = facts.missing_tools.join(", "); + checks.push(check( + "linux-tools", + Outcome::Warn, + format!("Optional helper tools not found: {list}"), + format!( + "Install {list} with your package manager for background clipboard sync and opening files or links." + ), + None, + )); + } + + #[cfg(target_os = "linux")] + for (id, title, ok, detail, fix) in &facts.linux_checks { + checks.push(check( + id, + if *ok { Outcome::Ok } else { Outcome::Warn }, + title.clone(), + detail.clone(), + *fix, + )); + } + // Each phone. for (i, d) in facts.devices.iter().enumerate() { let name = &d.name; @@ -373,7 +438,7 @@ fn ago(seconds: i64) -> String { } } -/// Runs a fix (blocking: the firewall one waits for Windows' prompt). +/// Runs a fix (blocking: the firewall one waits for the elevation prompt). /// Returns whether anything was done. pub fn fix(id: &str) -> bool { match id { @@ -395,6 +460,9 @@ pub fn fix(id: &str) -> bool { true } "firewall-settings" => windows_checks::open_firewall_settings(), + #[cfg(target_os = "linux")] + "network-settings" => windows_checks::open_network_settings(), + #[cfg(not(target_os = "linux"))] "network-settings" => crate::win::shell::open_url("ms-settings:network-status").is_ok(), "reconnect" => { if let Some(node) = core_host::node() { @@ -428,6 +496,10 @@ mod tests { networks: vec![("Home".into(), true)], firewall: Some(windows_checks::Firewall { on: true, allowed: true, blocked: false }), devices: vec![device_fact("Pixel", LinkState::Online { path: ConnectionPath::Lan, rtt_ms: 4 })], + #[cfg(target_os = "linux")] + missing_tools: Vec::new(), + #[cfg(target_os = "linux")] + linux_checks: Vec::new(), } } @@ -565,4 +637,33 @@ mod tests { assert_eq!(ago(5400), "an hour ago"); assert_eq!(ago(3 * 86400), "3 days ago"); } + + #[cfg(target_os = "linux")] + #[test] + fn includes_linux_readiness_checks_and_platform_titles() { + let mut f = facts(); + f.linux_checks = vec![ + ( + "linux-appindicator".into(), + "GNOME system tray extension not detected".into(), + false, + "Install gnome-shell-extension-appindicator".into(), + None, + ), + ( + "linux-fuse".into(), + "Phone storage can open in your file manager".into(), + true, + String::new(), + None, + ), + ]; + let checks = diagnose(&f, 0); + let fw = checks.iter().find(|c| c.id == "firewall").expect("firewall check"); + assert_eq!(fw.title, "The firewall lets your phone connect"); + let appind = checks.iter().find(|c| c.id == "linux-appindicator").expect("appindicator check"); + assert_eq!(appind.outcome, Outcome::Warn); + let fuse = checks.iter().find(|c| c.id == "linux-fuse").expect("fuse check"); + assert_eq!(fuse.outcome, Outcome::Ok); + } } diff --git a/desktop/app/src/linux/audio.rs b/desktop/app/src/linux/audio.rs index 7f4ee91f..f4a8b32a 100644 --- a/desktop/app/src/linux/audio.rs +++ b/desktop/app/src/linux/audio.rs @@ -1,36 +1,533 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Linux speaker volume, mute, output device enumeration, microphone mute +//! state, and event-driven change notifications via PipeWire (`wpctl`) and +//! PulseAudio (`pactl`). + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::{OnceLock, mpsc}; +use std::time::Duration; use nectarlink_core::AudioOutputDevice; +static WATCH_STARTED: OnceLock<()> = OnceLock::new(); + +/// Parses `wpctl get-volume` output such as `"Volume: 0.45"` or +/// `"Volume: 0.45 [MUTED]"` into `(volume_percent, muted)`. +pub(crate) fn parse_wpctl_volume(output: &str) -> Option<(u8, bool)> { + let muted = output.contains("[MUTED]"); + let after_colon = output.split_once("Volume:")?.1; + let vol_token = after_colon.split_whitespace().next()?; + let val = vol_token.parse::().ok()?; + if !val.is_finite() { + return None; + } + let pct = (val * 100.0).round().clamp(0.0, 100.0) as u8; + Some((pct, muted)) +} + +/// Parses `pactl get-sink-mute` / `pactl get-source-mute` output such as +/// `"Mute: yes"` or `"Mute: no"`. +pub(crate) fn parse_pactl_mute(mute_out: &str) -> Option { + let rhs = mute_out.split_once(':').map(|(_, r)| r).unwrap_or(mute_out).trim(); + if rhs.eq_ignore_ascii_case("yes") || rhs.eq_ignore_ascii_case("true") || rhs == "1" { + Some(true) + } else if rhs.eq_ignore_ascii_case("no") || rhs.eq_ignore_ascii_case("false") || rhs == "0" { + Some(false) + } else { + None + } +} + +/// Parses `pactl get-sink-volume` and optional `pactl get-sink-mute` output +/// into `(volume_percent, muted)`. +pub(crate) fn parse_pactl_volume_and_mute(vol_out: &str, mute_out: Option<&str>) -> Option<(u8, bool)> { + let pct_token = vol_out.split_whitespace().find_map(|tok| tok.strip_suffix('%'))?; + let pct_val = pct_token.trim().parse::().ok()?; + if !pct_val.is_finite() { + return None; + } + let pct = pct_val.round().clamp(0.0, 100.0) as u8; + let muted = mute_out.and_then(parse_pactl_mute).unwrap_or(false); + Some((pct, muted)) +} + +/// Parses `pactl list sinks` output along with `pactl get-default-sink` into +/// sorted `AudioOutputDevice` entries (default first, then alphabetical by name). +pub(crate) fn parse_pactl_sinks(list_out: &str, default_sink: Option<&str>) -> Vec { + let default_trimmed = default_sink.map(str::trim).filter(|s| !s.is_empty()); + let mut out = Vec::new(); + let mut current_id: Option = None; + let mut current_desc: Option = None; + + let flush = |id: &mut Option, desc: &mut Option, out: &mut Vec| { + if let Some(sink_id) = id.take() { + let name = desc.take().filter(|d| !d.is_empty()).unwrap_or_else(|| sink_id.clone()); + let is_default = default_trimmed == Some(sink_id.as_str()); + out.push(AudioOutputDevice { id: sink_id, name, is_default }); + } else { + *desc = None; + } + }; + + for raw_line in list_out.lines() { + let line = raw_line.trim(); + if line.starts_with("Sink #") { + flush(&mut current_id, &mut current_desc, &mut out); + } else if let Some(rest) = line.strip_prefix("Name:") { + let id = rest.trim(); + if !id.is_empty() && current_id.is_none() { + current_id = Some(id.to_owned()); + } + } else if let Some(rest) = line.strip_prefix("Description:") { + let desc = rest.trim(); + if !desc.is_empty() && current_desc.is_none() { + current_desc = Some(desc.to_owned()); + } + } + } + flush(&mut current_id, &mut current_desc, &mut out); + + out.sort_by(|a, b| b.is_default.cmp(&a.is_default).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Parses the `Sinks:` section of `wpctl status` into sorted `AudioOutputDevice` +/// entries (default first, then alphabetical by name). +pub(crate) fn parse_wpctl_status_sinks(status_out: &str) -> Vec { + let mut out = Vec::new(); + let mut in_sinks = false; + + for raw_line in status_out.lines() { + let stripped = raw_line + .trim_start_matches(|c: char| { + c.is_whitespace() || matches!(c, '│' | '├' | '└' | '─' | '|' | '+' | '-') + }) + .trim(); + if stripped.is_empty() { + continue; + } + if stripped == "Sinks:" { + in_sinks = true; + continue; + } + if !in_sinks { + continue; + } + let has_tree_indent = + raw_line.starts_with(char::is_whitespace) || raw_line.starts_with(['│', '├', '└', '|']); + if !has_tree_indent || stripped.ends_with(':') { + break; + } + + let (is_default, after_star) = match stripped.strip_prefix('*') { + Some(rest) => (true, rest.trim_start()), + None => (false, stripped), + }; + let Some((id_part, rest)) = after_star.split_once('.') else { + continue; + }; + let id = id_part.trim(); + if id.is_empty() || !id.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let raw_name = match rest.rfind(" [") { + Some(idx) => &rest[..idx], + None => rest, + }; + let name = raw_name.trim(); + if !name.is_empty() { + out.push(AudioOutputDevice { id: id.to_owned(), name: name.to_owned(), is_default }); + } + } + + out.sort_by(|a, b| b.is_default.cmp(&a.is_default).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Resolves a numeric PipeWire sink node ID for `wpctl set-default` from `target` +/// (which may already be numeric, a display name in `wpctl status`, or a node name +/// in `wpctl status --name`). +pub(crate) fn resolve_wpctl_sink_node_id( + status_out: &str, + status_name_out: Option<&str>, + target: &str, +) -> Option { + let trimmed = target.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.chars().all(|c| c.is_ascii_digit()) { + return Some(trimmed.to_owned()); + } + for sink in parse_wpctl_status_sinks(status_out) { + if sink.id == trimmed || sink.name.eq_ignore_ascii_case(trimmed) { + return Some(sink.id); + } + } + if let Some(name_out) = status_name_out { + for sink in parse_wpctl_status_sinks(name_out) { + if sink.id == trimmed || sink.name.eq_ignore_ascii_case(trimmed) { + return Some(sink.id); + } + } + } + None +} + +/// Returns `true` when a `pactl subscribe` event line indicates a sink, source, +/// or server state/device change (ignoring per-stream `sink-input` / `source-output` noise). +pub(crate) fn parse_pactl_subscribe_line(line: &str) -> bool { + let trimmed = line.trim(); + if !trimmed.starts_with("Event '") { + return false; + } + (trimmed.contains(" on sink #") + || trimmed.ends_with(" on sink") + || trimmed.contains(" on source #") + || trimmed.ends_with(" on source") + || trimmed.contains(" on server #") + || trimmed.ends_with(" on server")) + && !trimmed.contains("sink-input") + && !trimmed.contains("source-output") +} + +const CMD_TIMEOUT: Duration = Duration::from_millis(800); + +/// Reads the default speaker endpoint's `(volume_percent, muted)`. pub fn speaker_state() -> Option<(u8, bool)> { + if let Some(out) = super::run_cmd_timeout("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"], CMD_TIMEOUT) + && out.status.success() + && let Some(state) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + return Some(state); + } + + if let Some(vol_out) = + super::run_cmd_timeout("pactl", &["get-sink-volume", "@DEFAULT_SINK@"], CMD_TIMEOUT) + && vol_out.status.success() + { + let mute_out = super::run_cmd_timeout("pactl", &["get-sink-mute", "@DEFAULT_SINK@"], CMD_TIMEOUT) + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + if let Some(state) = + parse_pactl_volume_and_mute(&String::from_utf8_lossy(&vol_out.stdout), mute_out.as_deref()) + { + return Some(state); + } + } + None } -pub fn set_speaker_volume(_level: u8) -> Result<(), String> { + +/// Sets the default speaker endpoint's master volume (`0..=100`). +pub fn set_speaker_volume(level: u8) -> Result<(), String> { + let clamped = level.min(100); + let arg = format!("{clamped}%"); + if super::run_cmd_timeout("wpctl", &["set-volume", "@DEFAULT_AUDIO_SINK@", &arg], CMD_TIMEOUT) + .is_some_and(|o| o.status.success()) + { + return Ok(()); + } + let _ = super::run_cmd_timeout("pactl", &["set-sink-volume", "@DEFAULT_SINK@", &arg], CMD_TIMEOUT); Ok(()) } -pub fn set_speaker_mute(_muted: bool) -> Result<(), String> { + +/// Sets the default speaker endpoint's mute state. +pub fn set_speaker_mute(muted: bool) -> Result<(), String> { + let flag = if muted { "1" } else { "0" }; + if super::run_cmd_timeout("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", flag], CMD_TIMEOUT) + .is_some_and(|o| o.status.success()) + { + return Ok(()); + } + let _ = super::run_cmd_timeout("pactl", &["set-sink-mute", "@DEFAULT_SINK@", flag], CMD_TIMEOUT); Ok(()) } + +/// Steps the default speaker volume up by one increment (2%) and unmutes if muted. pub fn speaker_volume_up() -> Result<(u8, bool), String> { - Ok((50, false)) + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_add(2).min(100); + set_speaker_volume(next)?; + if muted { + set_speaker_mute(false)?; + } + Ok((next, false)) } + +/// Steps the default speaker volume down by one increment (2%). pub fn speaker_volume_down() -> Result<(u8, bool), String> { - Ok((50, false)) + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_sub(2); + set_speaker_volume(next)?; + Ok((next, muted)) } + +/// Toggles mute on the default speaker endpoint and returns the new muted state. pub fn toggle_speaker_mute() -> Result { - Ok(false) + let (_, muted) = speaker_state().unwrap_or((50, false)); + let next = !muted; + set_speaker_mute(next)?; + Ok(next) } + +/// Reads whether the default capture (microphone) endpoint is muted. pub fn mic_muted() -> Option { + if let Some(out) = super::run_cmd_timeout("wpctl", &["get-volume", "@DEFAULT_AUDIO_SOURCE@"], CMD_TIMEOUT) + && out.status.success() + && let Some((_, muted)) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + return Some(muted); + } + + if let Some(out) = super::run_cmd_timeout("pactl", &["get-source-mute", "@DEFAULT_SOURCE@"], CMD_TIMEOUT) + && out.status.success() + && let Some(muted) = parse_pactl_mute(&String::from_utf8_lossy(&out.stdout)) + { + return Some(muted); + } + None } + +/// Toggles mute on the default capture (microphone) endpoint and returns the new muted state. pub fn toggle_mic_mute() -> Result { - Ok(false) + let current = mic_muted().unwrap_or(false); + let next = !current; + let flag = if next { "1" } else { "0" }; + if super::run_cmd_timeout("wpctl", &["set-mute", "@DEFAULT_AUDIO_SOURCE@", flag], CMD_TIMEOUT) + .is_some_and(|o| o.status.success()) + { + return Ok(next); + } + let _ = super::run_cmd_timeout("pactl", &["set-source-mute", "@DEFAULT_SOURCE@", flag], CMD_TIMEOUT); + Ok(next) } + +/// Lists active audio output sinks on this PC, marking the current default +/// output (`is_default: true`) and sorting the default first. pub fn output_devices() -> Vec { + if let Some(wpctl_out) = super::run_cmd_timeout("wpctl", &["status"], CMD_TIMEOUT) + && wpctl_out.status.success() + { + let parsed = parse_wpctl_status_sinks(&String::from_utf8_lossy(&wpctl_out.stdout)); + if !parsed.is_empty() { + return parsed; + } + } + + let default_sink = super::run_cmd_timeout("pactl", &["get-default-sink"], CMD_TIMEOUT) + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + .filter(|s| !s.is_empty()); + + if let Some(list_out) = super::run_cmd_timeout("pactl", &["list", "sinks"], CMD_TIMEOUT) + && list_out.status.success() + { + let parsed = parse_pactl_sinks(&String::from_utf8_lossy(&list_out.stdout), default_sink.as_deref()); + if !parsed.is_empty() { + return parsed; + } + } + Vec::new() } -pub fn set_default_output_device(_id: &str) -> Result<(), String> { - Ok(()) + +/// Switches the system default output sink to `id`. +pub fn set_default_output_device(id: &str) -> Result<(), String> { + let trimmed = id.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || trimmed.chars().any(char::is_control) { + return Err("output device id is invalid".to_string()); + } + + let status_out = super::run_cmd_timeout("wpctl", &["status"], CMD_TIMEOUT) + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let status_name_out = super::run_cmd_timeout("wpctl", &["status", "--name"], CMD_TIMEOUT) + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + + if let Some(node_id) = resolve_wpctl_sink_node_id(&status_out, status_name_out.as_deref(), trimmed) + && super::run_cmd_timeout("wpctl", &["set-default", &node_id], CMD_TIMEOUT) + .is_some_and(|o| o.status.success()) + { + return Ok(()); + } + + if super::run_cmd_timeout("pactl", &["set-default-sink", trimmed], CMD_TIMEOUT) + .is_some_and(|o| o.status.success()) + { + return Ok(()); + } + + Err(format!("audio backend could not set default output device '{trimmed}'")) +} + +/// Subscribes to PipeWire/PulseAudio sink/source/server events and invokes +/// `changed` after coalescing rapid event bursts over 150 ms. +pub fn watch(changed: fn()) { + if WATCH_STARTED.set(()).is_err() { + return; + } + let _ = std::thread::Builder::new().name("audio-watch".into()).spawn(move || watch_thread(changed)); +} + +fn watch_thread(changed: fn()) { + use std::os::unix::process::CommandExt; + + let mut cmd = Command::new("pactl"); + cmd.arg("subscribe").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + // SAFETY: `prctl(PR_SET_PDEATHSIG, SIGTERM)` is async-signal-safe. + unsafe { + cmd.pre_exec(|| { + let _ = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + Ok(()) + }); + } + let Ok(mut child) = cmd.spawn() else { + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return; + }; + + let (tx, rx) = mpsc::channel::<()>(); + let _ = std::thread::Builder::new().name("audio-watch-reader".into()).spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if parse_pactl_subscribe_line(&line) && tx.send(()).is_err() { + break; + } + } + }); + + while rx.recv().is_ok() { + while rx.recv_timeout(Duration::from_millis(150)).is_ok() {} + changed(); + } + + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_wpctl_volume_handles_normal_and_muted_lines() { + assert_eq!(parse_wpctl_volume("Volume: 0.45\n"), Some((45, false))); + assert_eq!(parse_wpctl_volume("Volume: 0.72 [MUTED]\n"), Some((72, true))); + assert_eq!(parse_wpctl_volume("Volume: 1.35"), Some((100, false))); + assert_eq!(parse_wpctl_volume("garbage"), None); + } + + #[test] + fn parse_pactl_volume_and_mute_extracts_percentage_and_mute() { + let vol = "Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB\n"; + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: no\n")), Some((50, false))); + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: yes\n")), Some((50, true))); + assert_eq!(parse_pactl_volume_and_mute("invalid output", None), None); + } + + #[test] + fn parse_pactl_sinks_parses_and_sorts_default_first() { + let sample = "\ +Sink #41 +\tState: RUNNING +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Audio Analog Stereo +\tVolume: front-left: 40000 / 61% + +Sink #52 +\tState: IDLE +\tName: bluez_output.AA_BB_CC_DD_EE_FF.1 +\tDescription: Pixel Buds Pro +"; + let devices = parse_pactl_sinks(sample, Some("bluez_output.AA_BB_CC_DD_EE_FF.1")); + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].id, "bluez_output.AA_BB_CC_DD_EE_FF.1"); + assert_eq!(devices[0].name, "Pixel Buds Pro"); + assert!(devices[0].is_default); + assert_eq!(devices[1].id, "alsa_output.pci-0000_00_1f.3.analog-stereo"); + assert_eq!(devices[1].name, "Built-in Audio Analog Stereo"); + assert!(!devices[1].is_default); + } + + #[test] + fn parse_wpctl_status_sinks_parses_pipewire_tree_and_resolves_node_id() { + let status = "\ +PipeWire 'pipewire-0' [1.0.5, user@host, cookie:1234] + └─ Clients: + 32. WirePlumber [1.0.5, user@host, pid:999] + +Audio + ├─ Devices: + │ 42. Built-in Audio [alsa] + │ + ├─ Sinks: + │ 46. Built-in Audio Analog Stereo [vol: 0.45] + │ * 58. Pixel Buds Pro [vol: 0.80 MUTED] + │ + ├─ Sources: + │ * 47. Built-in Audio Analog Stereo [vol: 1.00] +"; + let status_name = "\ +Audio + ├─ Sinks: + │ 46. alsa_output.pci-0000_00_1f.3.analog-stereo [vol: 0.45] + │ * 58. bluez_output.AA_BB_CC_DD_EE_FF.1 [vol: 0.80 MUTED] + ├─ Sources: +"; + let devices = parse_wpctl_status_sinks(status); + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].id, "58"); + assert_eq!(devices[0].name, "Pixel Buds Pro"); + assert!(devices[0].is_default); + assert_eq!(devices[1].id, "46"); + assert_eq!(devices[1].name, "Built-in Audio Analog Stereo"); + assert!(!devices[1].is_default); + + assert_eq!(resolve_wpctl_sink_node_id(status, Some(status_name), "58").as_deref(), Some("58")); + assert_eq!( + resolve_wpctl_sink_node_id(status, Some(status_name), "Built-in Audio Analog Stereo").as_deref(), + Some("46") + ); + assert_eq!( + resolve_wpctl_sink_node_id( + status, + Some(status_name), + "alsa_output.pci-0000_00_1f.3.analog-stereo" + ) + .as_deref(), + Some("46") + ); + assert!(resolve_wpctl_sink_node_id(status, Some(status_name), "nonexistent").is_none()); + } + + #[test] + fn parse_pactl_subscribe_line_filters_relevant_events() { + assert!(parse_pactl_subscribe_line("Event 'change' on sink #0")); + assert!(parse_pactl_subscribe_line("Event 'change' on source #2")); + assert!(parse_pactl_subscribe_line("Event 'change' on server #0")); + assert!(!parse_pactl_subscribe_line("Event 'change' on sink-input #44")); + assert!(!parse_pactl_subscribe_line("Event 'change' on source-output #12")); + assert!(!parse_pactl_subscribe_line("Event 'new' on client #99")); + } + + #[test] + fn volume_stepping_increments_by_two_percent_and_unmutes_on_up() { + let _ = set_speaker_volume(40); + let _ = set_speaker_mute(true); + let (vol_up, muted_after_up) = speaker_volume_up().unwrap(); + assert!(!muted_after_up); + assert!(vol_up <= 100); + + let (vol_down, _) = speaker_volume_down().unwrap(); + assert!(vol_down <= 100); + } } -pub fn watch(_changed: fn()) {} diff --git a/desktop/app/src/linux/audio_out.rs b/desktop/app/src/linux/audio_out.rs index 21aee306..bd62c1ea 100644 --- a/desktop/app/src/linux/audio_out.rs +++ b/desktop/app/src/linux/audio_out.rs @@ -1,19 +1,335 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Low-latency 16-bit signed little-endian PCM playback on Linux via +//! PulseAudio (`pacat`), PipeWire (`pw-play`), or ALSA (`aplay`), backed by an +//! adaptive jitter buffer with smooth sample interpolation. -use std::io; +use std::collections::VecDeque; +use std::io::{Result, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, SyncSender, TrySendError}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; -pub struct Player; +const BYTES_PER_SAMPLE: usize = 2; +const JITTER_START_MS: usize = 40; +const JITTER_LOW_MS: usize = 20; +const JITTER_TARGET_MS: usize = 80; +const JITTER_HIGH_MS: usize = 160; +const JITTER_MAX_MS: usize = 220; + +/// Linearly interpolates two interleaved `s16le` frames (`a` and `b`) into `out`. +pub(crate) fn interpolate_frame_s16le(a: &[u8], b: &[u8], out: &mut [u8]) { + let len = a.len().min(b.len()).min(out.len()) & !1; + for i in (0..len).step_by(2) { + let sa = i16::from_le_bytes([a[i], a[i + 1]]); + let sb = i16::from_le_bytes([b[i], b[i + 1]]); + let mid = ((i32::from(sa) + i32::from(sb)) / 2) as i16; + let bytes = mid.to_le_bytes(); + out[i] = bytes[0]; + out[i + 1] = bytes[1]; + } +} + +#[derive(Debug)] +struct Jitter { + pcm: VecDeque, + frame_bytes: usize, + start_bytes: usize, + low_bytes: usize, + target_bytes: usize, + high_bytes: usize, + max_bytes: usize, + primed: bool, + last_frame: Vec, +} + +impl Jitter { + fn new(rate: u32, channels: u16) -> Self { + let frame_bytes = (channels as usize).max(1) * BYTES_PER_SAMPLE; + let bytes_per_ms = ((rate as usize) * frame_bytes / 1000).max(frame_bytes); + let align = |ms: usize| { + let raw = (ms * bytes_per_ms).max(frame_bytes); + (raw / frame_bytes) * frame_bytes + }; + Self { + pcm: VecDeque::with_capacity(align(JITTER_MAX_MS)), + frame_bytes, + start_bytes: align(JITTER_START_MS), + low_bytes: align(JITTER_LOW_MS), + target_bytes: align(JITTER_TARGET_MS), + high_bytes: align(JITTER_HIGH_MS), + max_bytes: align(JITTER_MAX_MS), + primed: false, + last_frame: vec![0u8; frame_bytes], + } + } + + fn push(&mut self, data: &[u8]) { + let usable = (data.len() / self.frame_bytes) * self.frame_bytes; + if usable == 0 { + return; + } + self.pcm.extend(&data[..usable]); + if self.pcm.len() > self.max_bytes { + let drop = ((self.pcm.len() - self.target_bytes) / self.frame_bytes) * self.frame_bytes; + self.pcm.drain(..drop.min(self.pcm.len())); + } + if self.pcm.len() >= self.start_bytes { + self.primed = true; + } + } + + fn pull(&mut self, dst: &mut [u8], frames: usize) -> bool { + let total_bytes = (frames * self.frame_bytes).min(dst.len()); + if !self.primed { + dst[..total_bytes].fill(0); + return false; + } + + if self.pcm.len() > self.high_bytes && frames >= 8 { + for _ in 0..self.frame_bytes { + let _ = self.pcm.pop_front(); + } + } + + let available_frames = self.pcm.len() / self.frame_bytes; + if available_frames == 0 { + self.primed = false; + self.last_frame.fill(0); + dst[..total_bytes].fill(0); + return false; + } + + let stretch = self.pcm.len() < self.low_bytes && frames >= 16; + let mut written = 0usize; + let mut produced_frames = 0usize; + let mut next_peek = vec![0u8; self.frame_bytes]; + + while produced_frames < frames && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for b in &mut self.last_frame { + *b = self.pcm.pop_front().unwrap_or(0); + } + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + written += self.frame_bytes; + produced_frames += 1; + + if stretch && produced_frames == frames / 2 && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for (i, b) in next_peek.iter_mut().enumerate() { + *b = self.pcm.get(i).copied().unwrap_or(0); + } + interpolate_frame_s16le( + &self.last_frame, + &next_peek, + &mut dst[written..written + self.frame_bytes], + ); + } else { + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + } + written += self.frame_bytes; + produced_frames += 1; + } + } else { + self.primed = false; + dst[written..total_bytes].fill(0); + break; + } + } + true + } +} + +fn spawn_pcm_sink(rate: u32, channels: u16, device_id: Option<&str>) -> Option { + let rate_s = rate.to_string(); + let ch_s = channels.to_string(); + let dev = device_id.map(str::trim).filter(|s| { + !s.is_empty() && *s != "default" && !s.starts_with('-') && !s.chars().any(char::is_control) + }); + + let mut pacat = Command::new("pacat"); + pacat.args([ + "--playback", + "--raw", + "--format=s16le", + &format!("--rate={rate_s}"), + &format!("--channels={ch_s}"), + "--latency-msec=20", + ]); + if let Some(d) = dev { + pacat.arg(format!("--device={d}")); + } + if let Ok(child) = pacat.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + let mut pw = Command::new("pw-play"); + pw.args(["--raw", "--format=s16", &format!("--rate={rate_s}"), &format!("--channels={ch_s}")]); + if let Some(d) = dev { + pw.arg(format!("--target={d}")); + } + pw.arg("-"); + if let Ok(child) = pw.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + Command::new("aplay") + .args(["-q", "-t", "raw", "-f", "S16_LE", "-r", &rate_s, "-c", &ch_s, "--buffer-size=1920", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +#[derive(Debug)] +pub struct Player { + tx: SyncSender>, + stop: Arc, + handle: Option>, +} impl Player { - pub fn start(_rate: u32, _channels: u16) -> io::Result { - Ok(Player) + pub fn start(rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, None) + } + + pub fn start_on(device: Option, rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, device.as_deref()) + } + + pub fn start_for_device(rate: u32, channels: u16, device_id: Option<&str>) -> Result { + let clean_rate = rate.max(8_000); + let clean_channels = channels.max(1); + let initial_child = spawn_pcm_sink(clean_rate, clean_channels, device_id).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no audio playback backend (pacat, pw-play, or aplay) available", + ) + })?; + + let (tx, rx) = mpsc::sync_channel::>(64); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let dev_owned = device_id.map(str::to_owned); + let mut child_cell = Some(initial_child); + + let handle = thread::Builder::new().name("nectarlink-audio-out".into()).spawn(move || { + let mut jitter = Jitter::new(clean_rate, clean_channels); + let frame_bytes = jitter.frame_bytes; + let frames_per_tick = ((clean_rate as usize) / 100).max(1); + let mut scratch = vec![0u8; frames_per_tick * frame_bytes]; + let mut child = child_cell.take(); + let tick = Duration::from_millis(10); + let mut next_tick = Instant::now() + tick; + + while !thread_stop.load(Ordering::Acquire) { + while let Ok(chunk) = rx.try_recv() { + jitter.push(&chunk); + } + let has_audio = jitter.pull(&mut scratch, frames_per_tick); + if has_audio + && let Some(proc) = child.as_mut() + && let Some(stdin) = proc.stdin.as_mut() + && stdin.write_all(&scratch).is_err() + { + let _ = proc.kill(); + let _ = proc.wait(); + child = spawn_pcm_sink(clean_rate, clean_channels, dev_owned.as_deref()); + } + let now = Instant::now(); + if next_tick > now { + thread::sleep(next_tick - now); + } else if now.duration_since(next_tick) > Duration::from_millis(50) { + next_tick = now; + } + next_tick += tick; + } + + if let Some(mut proc) = child { + drop(proc.stdin.take()); + let _ = proc.kill(); + let _ = proc.wait(); + } + })?; + + Ok(Player { tx, stop, handle: Some(handle) }) + } + + pub fn push(&self, data: Vec) { + if data.is_empty() { + return; + } + match self.tx.try_send(data) { + Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {} + } + } +} + +impl Drop for Player { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } } - pub fn start_on(_device: Option, _rate: u32, _channels: u16) -> io::Result { - Ok(Player) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_waits_for_prime_threshold_before_emitting_audio() { + let mut jitter = Jitter::new(48_000, 2); + let mut out = vec![0xFFu8; 480 * 4]; + jitter.push(&vec![0x11u8; 480 * 4]); + assert!(!jitter.pull(&mut out, 480)); + assert!(out.iter().all(|&b| b == 0)); + + jitter.push(&vec![0x22u8; 480 * 4 * 4]); + assert!(jitter.pull(&mut out, 480)); + assert!(out.iter().any(|&b| b != 0)); } - pub fn start_for_device(_rate: u32, _channels: u16, _device_id: Option<&str>) -> io::Result { - Ok(Player) + + #[test] + fn jitter_caps_overflow_to_target_depth() { + let mut jitter = Jitter::new(48_000, 2); + jitter.push(&vec![0x33u8; jitter.max_bytes + 4096]); + assert!(jitter.pcm.len() <= jitter.max_bytes); + assert!(jitter.pcm.len() >= jitter.target_bytes); + } + + #[test] + fn interpolate_frame_s16le_averages_per_channel_samples() { + let mut a = Vec::new(); + a.extend_from_slice(&1000i16.to_le_bytes()); + a.extend_from_slice(&(-2000i16).to_le_bytes()); + + let mut b = Vec::new(); + b.extend_from_slice(&3000i16.to_le_bytes()); + b.extend_from_slice(&(-1000i16).to_le_bytes()); + + let mut out = [0u8; 4]; + interpolate_frame_s16le(&a, &b, &mut out); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), 2000); + assert_eq!(i16::from_le_bytes([out[2], out[3]]), -1500); + } + + #[test] + fn player_starts_accepts_pushes_and_drops_cleanly() { + match Player::start(48_000, 2) { + Ok(player) => { + player.push(vec![0u8; 1920]); + drop(player); + } + Err(e) => { + assert_eq!(e.kind(), std::io::ErrorKind::NotFound); + } + } } - pub fn push(&self, _data: Vec) {} } diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs index 248b5fbf..91fa0f5f 100644 --- a/desktop/app/src/linux/autostart.rs +++ b/desktop/app/src/linux/autostart.rs @@ -1,20 +1,183 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! XDG autostart management (`~/.config/autostart/nectarlink.desktop`) on Linux. -use std::{io, path::Path}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; +const DESKTOP_FILE_NAME: &str = "nectarlink.desktop"; + +/// Escapes a single argument for placement inside double quotes (`"..."`) on a +/// FreeDesktop `.desktop` `Exec=` line (`\`, `"`, `` ` ``, `$`, and literal `%`). +pub(crate) fn escape_desktop_exec_arg(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for c in raw.chars() { + if c == '\n' || c == '\r' || c.is_control() { + continue; + } + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '`' => out.push_str("\\`"), + '$' => out.push_str("\\$"), + '%' => out.push_str("%%"), + _ => out.push(c), + } + } + out +} + +/// The command line written into the autostart entry's `Exec=` line. pub fn command(exe: &Path) -> String { - format!("\"{}\" --minimized", exe.display()) + format!("\"{}\" --minimized", escape_desktop_exec_arg(&exe.to_string_lossy())) +} + +fn autostart_file_path_in(config_dir: &Path) -> PathBuf { + config_dir.join("autostart").join(DESKTOP_FILE_NAME) +} + +fn autostart_file_path() -> io::Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no XDG config directory"))?; + Ok(autostart_file_path_in(&config_dir)) } -pub fn set(_enabled: bool, _exe: &Path) -> io::Result<()> { - Ok(()) + +fn render_desktop_entry(exe: &Path) -> String { + let cmd = command(exe); + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=NectarLink\n\ + Comment=Link your Android phone and PC\n\ + Exec={cmd}\n\ + Icon=nectarlink\n\ + Terminal=false\n\ + Categories=Utility;Network;\n\ + StartupNotify=false\n\ + X-GNOME-Autostart-enabled=true\n" + ) +} + +fn set_in(config_dir: &Path, enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path_in(config_dir); + if enabled { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, render_desktop_entry(exe)) + } else { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +fn is_enabled_in(config_dir: &Path) -> bool { + let path = autostart_file_path_in(config_dir); + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + !contents.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case("Hidden=true") || t.eq_ignore_ascii_case("X-GNOME-Autostart-enabled=false") + }) +} + +/// Adds or removes the XDG autostart `.desktop` entry for `exe`. +pub fn set(enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path()?; + if let Some(config_dir) = path.parent().and_then(Path::parent) { + set_in(config_dir, enabled, exe) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "invalid XDG autostart path")) + } } + +/// Alias for checking whether the XDG autostart entry is currently present and enabled. pub fn is_enabled() -> bool { - false + dirs::config_dir().as_deref().is_some_and(is_enabled_in) +} + +/// Alias for enabling or disabling XDG autostart for the current executable. +pub fn set_enabled(enabled: bool) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + set(enabled, &exe).map_err(|e| e.to_string()) } -pub fn set_enabled(_enabled: bool) -> Result<(), String> { - Ok(()) + +/// Whether `exe` is installed in a standard Linux application location (rather +/// than run directly from a `target/` build directory). +pub fn is_installed_location(exe: &Path) -> bool { + if std::env::var_os("APPIMAGE").is_some() { + return true; + } + let s = exe.to_string_lossy(); + if s.contains("/target/debug/") || s.contains("/target/release/") { + return false; + } + exe.starts_with("/usr") + || exe.starts_with("/opt") + || s.contains("/.local/share/nectarlink-app/") + || s.contains("/.local/bin/") + || dirs::data_local_dir() + .is_some_and(|d| exe.starts_with(&d) || exe.starts_with(d.join("nectarlink-app"))) + || dirs::executable_dir().is_some_and(|d| exe.starts_with(d)) + || dirs::home_dir().is_some_and(|h| { + exe.starts_with(h.join(".local/share/nectarlink-app")) + || exe.starts_with(h.join(".local/bin")) + || exe.starts_with(h.join(".local/share")) + }) } -pub fn is_installed(_exe: &Path) -> bool { - false + +/// Whether `exe` is installed in a standard Linux application location (rather +/// than run directly from a `target/` build directory). +pub fn is_installed(exe: &Path) -> bool { + is_installed_location(exe) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_and_removes_xdg_autostart_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let exe = Path::new("/usr/bin/nectarlink-desktop"); + + assert!(!is_enabled_in(dir.path())); + set_in(dir.path(), true, exe).unwrap(); + assert!(is_enabled_in(dir.path())); + + let desktop = fs::read_to_string(autostart_file_path_in(dir.path())).unwrap(); + assert!(desktop.contains("Exec=\"/usr/bin/nectarlink-desktop\" --minimized")); + + set_in(dir.path(), false, exe).unwrap(); + assert!(!is_enabled_in(dir.path())); + // Removing again is a no-op. + set_in(dir.path(), false, exe).unwrap(); + } + + #[test] + fn detects_installed_vs_build_paths() { + assert!(is_installed(Path::new("/usr/bin/nectarlink-desktop"))); + assert!(is_installed(Path::new("/usr/local/bin/nectarlink-desktop"))); + assert!(is_installed(Path::new("/opt/nectarlink/nectarlink-desktop"))); + assert!(is_installed_location(Path::new( + "/home/user/.local/share/nectarlink-app/nectarlink-desktop" + ))); + assert!(is_installed_location(Path::new("/home/user/.local/bin/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/debug/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/release/nectarlink-desktop"))); + } + + #[test] + fn command_escapes_desktop_entry_special_characters() { + let exe = Path::new("/opt/my \"quoted\" $dir`1`\\bin%20/nectarlink-desktop"); + assert_eq!( + command(exe), + r#""/opt/my \"quoted\" \$dir\`1\`\\bin%%20/nectarlink-desktop" --minimized"# + ); + } } diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs index d167ec00..65aef076 100644 --- a/desktop/app/src/linux/ble_beacon.rs +++ b/desktop/app/src/linux/ble_beacon.rs @@ -1,9 +1,500 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Linux BLE proximity RSSI monitor via BlueZ D-Bus (`org.freedesktop.DBus.ObjectManager`). +//! Never spawns interactive `bluetoothctl` sessions or `scan on`, and matches only +//! paired NectarLink phones (or devices advertising the NectarLink BLE UUID / +//! manufacturer frame) when walk-away automation is enabled. + +use std::collections::HashMap; +use std::sync::{ + Condvar, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; use nectarlink_core::DeviceId; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +pub const COMPANY_ID: u16 = 0xFFFF; +pub const MAGIC: &[u8; 5] = b"NCTR\x01"; +pub const NEAR_RSSI_DBM: i16 = -72; +pub const WALK_AWAY_RSSI_DBM: i16 = -85; +pub const NECTARLINK_BLE_UUID: &str = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +static RUNNING: AtomicBool = AtomicBool::new(false); +static STOP_MU: Mutex = Mutex::new(false); +static STOP_CV: Condvar = Condvar::new(); +static WORKER: Mutex>> = Mutex::new(None); + +type ManagedObjects = HashMap>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairedPhone { + pub prefix: [u8; 8], + pub name: String, + pub model: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct BluezDeviceCandidate { + pub address: Option, + pub name: Option, + pub alias: Option, + pub rssi: Option, + pub uuids: Vec, + pub mfg_payload: Option>, +} + +#[derive(Debug, Default)] +pub struct RssiTracker { + peers: HashMap<[u8; 8], PeerRssiState>, +} + +#[derive(Debug, Clone, Default)] +struct PeerRssiState { + samples: [i16; 4], + count: usize, + idx: usize, + armed_near: bool, +} + +impl RssiTracker { + pub fn record(&mut self, prefix: [u8; 8], rssi: i16) -> bool { + let state = self.peers.entry(prefix).or_default(); + state.samples[state.idx] = rssi; + state.idx = (state.idx + 1) % state.samples.len(); + state.count = (state.count + 1).min(state.samples.len()); + if state.count < state.samples.len() { + return false; + } + let sum: i32 = state.samples.iter().map(|&v| i32::from(v)).sum(); + let avg = (sum / (state.samples.len() as i32)) as i16; + if avg >= NEAR_RSSI_DBM { + state.armed_near = true; + false + } else if state.armed_near && avg <= WALK_AWAY_RSSI_DBM { + state.armed_near = false; + true + } else { + false + } + } +} + +pub fn build_manufacturer_payload(device_id: &DeviceId) -> [u8; 13] { + let mut out = [0u8; 13]; + out[0..5].copy_from_slice(MAGIC); + out[5..13].copy_from_slice(&device_id.as_bytes()[0..8]); + out +} + +/// Validates the `NCTR\x01` magic header and extracts the peer device ID prefix. +/// Accepts either the 13-byte payload (5-byte magic + 8-byte prefix) or a 12-byte +/// payload (5-byte magic + 7-byte prefix, zero-padded in the 8th byte). +pub fn parse_manufacturer_payload(bytes: &[u8]) -> Option<[u8; 8]> { + if bytes.len() < 12 || &bytes[0..5] != MAGIC { + return None; + } + let mut prefix = [0u8; 8]; + if bytes.len() >= 13 { + prefix.copy_from_slice(&bytes[5..13]); + } else { + prefix[0..7].copy_from_slice(&bytes[5..12]); + } + Some(prefix) +} + +pub fn start(device_id: DeviceId) { + if RUNNING.swap(true, Ordering::SeqCst) { + return; + } + *STOP_MU.lock().unwrap_or_else(|e| e.into_inner()) = false; + let handle = thread::Builder::new().name("ble-beacon".into()).spawn(move || run_worker(device_id)).ok(); + if let Ok(mut guard) = WORKER.lock() { + *guard = handle; + } +} + +pub fn stop() { + RUNNING.store(false, Ordering::SeqCst); + { + let mut stopped = STOP_MU.lock().unwrap_or_else(|e| e.into_inner()); + *stopped = true; + STOP_CV.notify_all(); + } + let handle = WORKER.lock().ok().and_then(|mut guard| guard.take()); + if let Some(h) = handle { + let _ = h.join(); + } +} + +fn walk_away_lock_enabled() -> bool { + let rules = crate::settings::automation_rules(); + !crate::automations::matching_rules(&rules, crate::automations::TRIGGER_PHONE_WALKED_AWAY).is_empty() +} + +fn paired_phones_from_hub() -> Vec { + crate::core_host::host().hub.read(|s| { + s.devices + .iter() + .map(|d| { + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(&d.id.as_bytes()[0..8]); + PairedPhone { prefix, name: d.info.name.clone(), model: d.info.model.clone() } + }) + .collect() + }) +} + +fn fallback_peer_prefix(id_str: &str) -> [u8; 8] { + let mut out = [0u8; 8]; + let bytes = id_str.as_bytes(); + let start = bytes.len().saturating_sub(8); + for (dst, &src) in out.iter_mut().zip(&bytes[start..]) { + *dst = src; + } + out +} + +fn names_match(candidate: &str, phone_name: &str) -> bool { + let c = candidate.trim(); + let p = phone_name.trim(); + !c.is_empty() && !p.is_empty() && c.eq_ignore_ascii_case(p) +} + +/// Matches a BlueZ device candidate ONLY if: +/// 1. It carries a valid `NCTR\x01` manufacturer payload (not our own prefix), OR +/// 2. It advertises the NectarLink BLE service UUID (`f81d4fae-7dec-11d0-a765-00a0c91e6bf6`), OR +/// 3. Its `Name` or `Alias` matches a paired phone in `hub`. +/// +/// Arbitrary Bluetooth peripherals (headphones, mice, keyboards, etc.) never match. +pub fn match_nectarlink_device( + candidate: &BluezDeviceCandidate, + own_prefix: &[u8; 8], + paired_phones: &[PairedPhone], +) -> Option<[u8; 8]> { + if let Some(mfg_bytes) = &candidate.mfg_payload + && let Some(prefix) = parse_manufacturer_payload(mfg_bytes) + { + if &prefix == own_prefix { + return None; + } + if paired_phones.is_empty() || paired_phones.iter().any(|p| p.prefix == prefix) { + return Some(prefix); + } + } + + let find_matching_phone = || -> Option<&PairedPhone> { + paired_phones.iter().find(|phone| { + candidate.name.as_deref().is_some_and(|n| { + names_match(n, &phone.name) || phone.model.as_deref().is_some_and(|m| names_match(n, m)) + }) || candidate.alias.as_deref().is_some_and(|a| { + names_match(a, &phone.name) || phone.model.as_deref().is_some_and(|m| names_match(a, m)) + }) + }) + }; + + if candidate.uuids.iter().any(|u| u.eq_ignore_ascii_case(NECTARLINK_BLE_UUID)) { + if let Some(phone) = find_matching_phone() { + return Some(phone.prefix); + } + if let Some(first) = paired_phones.first() { + return Some(first.prefix); + } + if let Some(addr) = &candidate.address { + let prefix = fallback_peer_prefix(addr); + if &prefix != own_prefix { + return Some(prefix); + } + } + return None; + } + + find_matching_phone().map(|p| p.prefix) +} + +fn extract_string(val: &OwnedValue) -> Option { + <&str>::try_from(val).ok().map(str::to_owned) +} + +fn extract_i16(val: &OwnedValue) -> Option { + if let Ok(v) = i16::try_from(val) { + return Some(v); + } + if let Ok(v) = i32::try_from(val) { + return i16::try_from(v).ok(); + } + None +} + +fn extract_string_list(val: &OwnedValue) -> Vec { + if let Ok(list) = Vec::::try_from(val.clone()) { + return list; + } + let Value::Array(arr) = &**val else { + return Vec::new(); + }; + arr.iter().filter_map(|item| <&str>::try_from(item).ok().map(str::to_owned)).collect() +} + +fn extract_mfg_ffff(val: &OwnedValue) -> Option> { + let Value::Dict(dict) = &**val else { + return None; + }; + for (k, v) in dict.iter() { + let key_matches = u16::try_from(k).is_ok_and(|id| id == COMPANY_ID); + if !key_matches { + continue; + } + let inner = match v { + Value::Value(inner) => inner.as_ref(), + other => other, + }; + if let Value::Array(bytes_arr) = inner { + let mut out = Vec::with_capacity(bytes_arr.len()); + for item in bytes_arr.iter() { + out.push(u8::try_from(item).ok()?); + } + return Some(out); + } + } + None +} + +fn is_adapter_powered(conn: &zbus::blocking::Connection) -> bool { + if !super::dbus_name_has_owner(conn, "org.bluez") { + return false; + } + let Ok(reply) = conn.call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) else { + return false; + }; + let Ok(objects) = reply.body().deserialize::() else { + return false; + }; + objects.values().any(|ifaces| { + ifaces + .get("org.bluez.Adapter1") + .and_then(|props| props.get("Powered")) + .and_then(|v| bool::try_from(v).ok()) + .unwrap_or(false) + }) +} + +fn poll_bluez_devices(conn: &zbus::blocking::Connection, own_prefix: &[u8; 8], tracker: &mut RssiTracker) { + if !walk_away_lock_enabled() { + return; + } + let paired_phones = paired_phones_from_hub(); + if paired_phones.is_empty() { + return; + } + + let Ok(reply) = conn.call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) else { + return; + }; + let Ok(objects) = reply.body().deserialize::() else { + return; + }; + + for ifaces in objects.values() { + let Some(props) = ifaces.get("org.bluez.Device1") else { + continue; + }; + let Some(rssi) = props.get("RSSI").and_then(extract_i16) else { + continue; + }; + let candidate = BluezDeviceCandidate { + address: props.get("Address").and_then(extract_string), + name: props.get("Name").and_then(extract_string), + alias: props.get("Alias").and_then(extract_string), + rssi: Some(rssi), + uuids: props.get("UUIDs").map(extract_string_list).unwrap_or_default(), + mfg_payload: props.get("ManufacturerData").and_then(extract_mfg_ffff), + }; + if let Some(prefix) = match_nectarlink_device(&candidate, own_prefix, &paired_phones) + && tracker.record(prefix, rssi) + { + tracing::info!("BLE walk-away threshold reached for paired phone; firing trigger"); + crate::automations::fire_trigger(crate::automations::TRIGGER_PHONE_WALKED_AWAY); + } + } +} + +fn run_worker(device_id: DeviceId) { + let Ok(conn) = zbus::blocking::Connection::system() else { + tracing::debug!("BLE beacon: system D-Bus unavailable; exiting worker"); + RUNNING.store(false, Ordering::SeqCst); + return; + }; + if !is_adapter_powered(&conn) { + tracing::debug!("BLE beacon: no powered BlueZ adapter found; exiting worker"); + RUNNING.store(false, Ordering::SeqCst); + return; + } + + let mut own_prefix = [0u8; 8]; + own_prefix.copy_from_slice(&device_id.as_bytes()[0..8]); + let mut tracker = RssiTracker::default(); + + while RUNNING.load(Ordering::Relaxed) { + poll_bluez_devices(&conn, &own_prefix, &mut tracker); + + let guard = STOP_MU.lock().unwrap_or_else(|e| e.into_inner()); + let _ = STOP_CV + .wait_timeout_while(guard, POLL_INTERVAL, |stopped| !*stopped && RUNNING.load(Ordering::Relaxed)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_13_byte_manufacturer_payload_with_magic_and_id_prefix() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + assert_eq!(payload.len(), 13); + assert_eq!(&payload[0..5], b"NCTR\x01"); + assert_eq!(&payload[5..13], &raw[0..8]); + assert_eq!(COMPANY_ID, 0xFFFF); + } + + #[test] + fn manufacturer_payload_roundtrips_through_parser() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(0x10); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + let parsed = parse_manufacturer_payload(&payload).expect("valid payload should parse"); + assert_eq!(&parsed[..], &raw[0..8]); + + // Also supports 12-byte payloads (5-byte magic + 7-byte prefix). + let parsed_12 = parse_manufacturer_payload(&payload[0..12]).expect("12-byte payload should parse"); + assert_eq!(&parsed_12[0..7], &raw[0..7]); + assert_eq!(parsed_12[7], 0); + } + + #[test] + fn parser_rejects_short_or_wrong_magic_payloads() { + assert_eq!(parse_manufacturer_payload(b""), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x01short"), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x0212345678"), None); + assert_eq!(parse_manufacturer_payload(b"XXXX\x0112345678"), None); + } + + #[test] + fn rssi_tracker_fires_walk_away_only_after_armed_near() { + let mut tracker = RssiTracker::default(); + let peer = [1, 2, 3, 4, 5, 6, 7, 8]; + for _ in 0..4 { + assert!(!tracker.record(peer, -90)); + } + for _ in 0..4 { + assert!(!tracker.record(peer, -65)); + } + assert!(!tracker.record(peer, -88)); + assert!(!tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + assert!(tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + } + + #[test] + fn rssi_tracker_arms_at_near_threshold_and_fires_once_on_walk_away() { + let mut tracker = RssiTracker::default(); + let peer = [9, 8, 7, 6, 5, 4, 3, 2]; + // Exactly at NEAR_RSSI_DBM (-72) for 4 samples arms the tracker. + for _ in 0..4 { + assert!(!tracker.record(peer, NEAR_RSSI_DBM)); + } + // Dropping to WALK_AWAY_RSSI_DBM (-85) across 4 samples triggers once. + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(tracker.record(peer, WALK_AWAY_RSSI_DBM)); + // Subsequent distant samples do not re-trigger without re-arming near. + for _ in 0..4 { + assert!(!tracker.record(peer, -95)); + } + } + + #[test] + fn matches_only_paired_phones_or_nectarlink_uuid_and_ignores_peripherals() { + let own_prefix = [0xAA; 8]; + let phone_prefix = [1, 2, 3, 4, 5, 6, 7, 8]; + let paired = vec![PairedPhone { + prefix: phone_prefix, + name: "Pixel 9 Pro".into(), + model: Some("Pixel 9 Pro".into()), + }]; + + // Arbitrary Bluetooth headphones / mice must NEVER match. + let headphones = BluezDeviceCandidate { + address: Some("11:22:33:44:55:66".into()), + name: Some("Sony WH-1000XM5".into()), + alias: Some("Sony WH-1000XM5".into()), + rssi: Some(-90), + uuids: vec!["0000110b-0000-1000-8000-00805f9b34fb".into()], + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&headphones, &own_prefix, &paired), None); + + // Paired phone matched by Name/Alias. + let by_name = BluezDeviceCandidate { + address: Some("AA:BB:CC:DD:EE:FF".into()), + name: Some("pixel 9 pro".into()), + alias: None, + rssi: Some(-65), + uuids: Vec::new(), + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&by_name, &own_prefix, &paired), Some(phone_prefix)); + + // Device advertising the NectarLink BLE service UUID matches. + let by_uuid = BluezDeviceCandidate { + address: Some("AA:BB:CC:DD:EE:FF".into()), + name: Some("Android".into()), + alias: None, + rssi: Some(-65), + uuids: vec![NECTARLINK_BLE_UUID.to_uppercase()], + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&by_uuid, &own_prefix, &paired), Some(phone_prefix)); + } + + #[test] + fn start_and_stop_never_panic_when_ble_radio_is_absent_or_present() { + let id = DeviceId([0x42; 32]); + start(id); + stop(); + } -pub fn start(_device_id: DeviceId) { - let _ = crate::automations::TRIGGER_PHONE_WALKED_AWAY; + #[test] + fn start_and_stop_are_idempotent_without_bluez() { + let id = DeviceId([0x24; 32]); + start(id); + start(id); + stop(); + stop(); + } } -pub fn stop() {} diff --git a/desktop/app/src/linux/browser.rs b/desktop/app/src/linux/browser.rs index c859dd88..a39c48b9 100644 --- a/desktop/app/src/linux/browser.rs +++ b/desktop/app/src/linux/browser.rs @@ -1,11 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables, non_camel_case_types, clippy::upper_case_acronyms)] +//! Find the window that owns a media session on Linux, and read the page +//! address and the `