Compare commits

..

12 Commits

Author SHA1 Message Date
RustDesk
0fd1a0eecb Custom client no rebuild (#15774)
* feat(portable): load per-customer payload from a PE resource

Customizing a Windows client recompiled the packer for every customer,
because data.bin was baked in with include_bytes!. The generic payload is
identical across customers, so only the small per-customer delta needs to
vary: the branded runner exe, custom.txt and the icons.

The packer now also reads an RDPKG RCDATA resource holding a second blob in
the same format, and folds it over the compiled-in payload. A build can then
inject that resource into a prebuilt template instead of running cargo.

The executable to launch comes from the package trailer, and the extraction
directory follows its stem, which replaces the sed of APP_PREFIX. Where the
executable itself is not customized (sciter x86) it stays in the generic
payload and is only renamed, so the merge covers both shapes.

custom.txt keeps being written to disk next to the app: that is what the
client reads at startup and what the updater stages so a customization
survives an upgrade to a stock build.

Also fixes generate.py restoring os.curdir (the literal ".") instead of the
previous working directory, which left it inside the source folder.

CI: ship windows-aarch64 in the unsigned tarball, so ARM custom clients have
a template to build from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* ci: publish msi templates for custom client builds

Custom clients rebuild the msi through WiX for every customer, though the
package only differs by the app name, a few GUIDs and four files.

Build the msi once more per release with a __RDAPPNAME__ placeholder and ship
it unsigned in the unsigned tarball, so a customer's build can patch it rather
than run msbuild. It stays unsigned because patching would invalidate a
signature anyway.

Doing this in CI is what makes ARM custom clients possible: preprocess.py runs
the packaged exe to read its version and build date, so an arm64 msi can only
be produced on a native arm64 machine, which the runner already is and the
build agents are not. Patching runs no exe, so an x64 agent can then patch the
arm64 template.

preprocess.py rewrites res/msi in place and locates the app as <app-name>.exe
inside the dist, so the tree is reset around the second build and the dist copy
is renamed to match. Sciter x86 ships no msi and is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* refactor(msi): pass the app name to the printer custom actions

preprocess.py rewrote the CustomActions sources per customer so the printer
carried the app name, which meant the dll was recompiled for every custom
client and, worse, left the app name baked into a compiled binary.

Pass it through CustomActionData instead. Only the printer and its port ever
varied: the INF path and the driver name ship under their stock names and
preprocess.py already forced the driver name back to RustDesk, so a single
build of the dll now serves every custom client.

Both actions treat the name as optional and fall back to the stock name, so a
package built before this still installs and uninstalls its printer.

This also unblocks patching a prebuilt msi template, which cannot work while a
compiled dll contains the app name: replacing a string inside a PE would shift
everything after it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* ci: use an 8.3-safe placeholder for the msi template

WiX derives a short name for any name that is not valid 8.3, and a patch
cannot rewrite a truncated placeholder, so a long placeholder would leave the
package's short names pointing at it. RDAPPNAM is eight characters like
"RustDesk" and needs no short name, keeping the template as close to the
shipped package as the mechanism allows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* feat(msi): give a template its own cabinet for per-customer files

Rebranding recompressed the whole ~100MB payload because one cabinet held
everything. In template mode preprocess.py puts the handful of files a custom
client replaces on a second cabinet, so a patch rebuilds a few hundred KB and
leaves the payload cabinet alone. The shipped msi is built without template
mode and keeps its single cabinet.

The branding assets need conditional components. A stock build ships none of
them -- there is no icon.ico, icon.png or logo*.png, only icon.svg -- so the
template has to carry placeholders for the File rows to exist, and a customer
supplies whichever they want. Installing a placeholder unconditionally would
give a customer with no logo a placeholder image, where today a missing asset
means no logo at all: the client tries each candidate and treats the failure as
absence. So each optional asset installs only when its property says the
customer supplied one.

CI creates those placeholders and builds the template with the new mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* ci: build the msi template with a sentinel revision

preprocess.py appends a build-time revision as the fourth version field, so a
template built without one would bake the CI clock into every customer's
package. Revision 0 marks the field as the patcher's to fill in, and makes the
template deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* fix(portable): delete files a later package no longer carries

The extraction directory is wiped only when the packer's compiled-in timestamp
changes. That used to be per customer, because generate.py ran for each build;
now the packer is compiled once per release, so every customer and every
rebuild within a release share one timestamp and nothing is ever wiped.

A customer who removes their logo and rebuilds would therefore keep showing it:
the new package simply omits logo.png, and md5 skipping only covers files that
are still present. Record the package's paths in the extraction's meta file and
delete the ones a later package drops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* fix(portable): build the dropped-file path from plain components

meta.toml lives in a user-writable directory and now drives deletion, but the
traversal guard tested the normalised string while the join used the raw one.
Path::join replaces the base outright when handed an absolute path, so an
edited meta.toml could point remove_file anywhere.

The path is now rebuilt from Normal components only. A colon is rejected
explicitly rather than left to the host's parser: a drive-relative "C:x" parses
as a Normal component everywhere, and only a Windows host reads "C:/..." as a
prefix, so the same input escaped when the logic was exercised off-Windows --
which is what the new test catches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* fix(msi): pass the printer name in a format the custom action can read

[~] is MSI's escape for a NUL character, not the delimiter WcaReadStringFromCaData
splits on -- that is a literal wide char 128, which a Formatted property value
cannot carry -- and WcaGetProperty returns a null-terminated string anyway. So
the second field was unreachable: InstallPrinter always fell back to the stock
name and installed a printer and port called "RustDesk Printer" inside a
customer's branded package, while UninstallPrinter, whose data is a single field
and parsed fine, went looking for "Acme Printer" and left the real one behind
for good.

Both actions now read CustomActionData directly and split on a character that
cannot occur in a Windows path or in a validated app name. A package built
before this carries no separator and keeps the stock name, as it did.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm

* fix(portable): retry failed stale branding cleanup

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(portable): reject malformed RDPKG resources

Distinguish an absent customer package from an invalid resource and
propagate package errors instead of launching the stock payload.

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: format 2 files

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(msi): match process names case-insensitively during uninstall

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(custom-client): validate portable exclusion and MSI action data

Fail when --exclude-exe does not match a file, and propagate MSI
CustomActionData read failures while preserving legacy fallback behavior.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: generate.py, exclude-exe

Signed-off-by: fufesou <linlong1266@gmail.com>

* Revert "fix: generate.py, exclude-exe"

This reverts commit 5104664e95.

* fix: simple path fix in generate.py

Signed-off-by: fufesou <linlong1266@gmail.com>

* Remove useless comments

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(portable): remove expect() anyway

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(portable): validate executable path boundaries

Reject executables outside the source folder and
reuse the package path normalization logic during
stale file cleanup.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix, remove useless file

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-09-02 22:14:03 +08:00
Stephan Paternotte
dfb5804dd0 Update nl.rs (#16036)
Re. export and import.
Without detailed information, reference or examples from en.rs, de.rs or fr.rs, I have simply translated the three strings verbatim
2026-09-02 21:49:37 +08:00
21pages
957dfe8c96 feat: add admin and control role API scripts (#16035)
- add admin role CRUD and membership management
  - add non-protobuf control role operations

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 21:47:03 +08:00
XLion
c312385ffd Update tw.rs (#16031)
* Update tw.rs

* Update tw.rs

* Update tw.rs
2026-09-02 14:36:49 +08:00
palmoni5
f28ac38ccf feat: optionally sync clipboard between connected sessions (#15934)
* feat(clipboard): optionally sync clipboard between connected sessions

Clipboard content received from a remote session is written to the local
clipboard with an owner marker, so the client clipboard loop deliberately
skips re-broadcasting it to avoid echo loops. As a result, text copied in
one remote window could not be pasted in another connected remote window.

Add an opt-in local option (allow-sync-clipboard-between-sessions) that
relays Clipboard/MultiClipboards messages received from one session to
all other connected sessions, excluding the source session. Per-session
clipboard permissions and view-only mode are still respected via the
existing send path, and the owner marker on the receiving peers prevents
any echo back.

Desktop (flutter) only; file clipboard is not affected.

* fix(lang): propagate sync-clipboard-between-sessions-tip to all locale files

Add the new key to template.rs and every locale file per the localization
convention, move the en.rs entry to the end of the list, and drop comments
that only restated the names next to them.

* fix(lang): add the 'Sync clipboard between sessions' label to the localization catalog

The checkbox label goes through translate(), so add it to template.rs
and every locale file so non-English locales can translate it. en.rs is
skipped since the English display text is identical to the key.

* fix(clipboard): check the source session's full clipboard permission before relaying

The relay was gated only by the incoming clipboard_allowed check
(!disable_clipboard && !view_only). Gate it with
is_text_clipboard_required() instead, which additionally respects the
source session's server_clipboard_enabled and server_keyboard_enabled
state, matching the predicate already applied to destination sessions.
A message arriving after the source permission was revoked (or from a
non-conforming peer) is no longer propagated to other sessions. The
existing local update_clipboard behavior is unchanged.

* fix(lang): translate the new clipboard sync entries in all locale files

Fill the 'Sync clipboard between sessions' label and its tooltip in
every locale file instead of leaving them blank, following each file's
existing terminology. template.rs keeps the empty master entries.
2026-09-01 10:47:26 +08:00
rustdesk
28cf1836e6 bump to 1.5.0 2026-09-01 08:57:33 +08:00
Michael Clark
1ec1b9e7e3 fix: android: target API 36 (#15603)
* fix: android: target API 35

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: handle API 35 foreground service types

Integrate the foreground-service and MediaProjection lifecycle changes
from fufesou/rustdesk#68 while leaving storage permission handling to
#15602.

Co-authored-by: fufesou <linlong1266@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: bump required android sdk version to 36, per recent google requirement change.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix(android): clear microphone FGS type when capture stops

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): harden API 36 capture service lifecycle

- isolate MediaProjection callbacks per session
- keep foreground service types in sync with capture state
- handle audio startup failures and shared frame ownership
- upgrade AGP to 8.10.1 for API 36 support

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): reset capture state on FGS update failure

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): recover capture after projection failure

Propagate virtual display startup failures, clean up partial video
resources, and resume capture after media projection is reauthorized.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): preserve voice call during projection replacement

Keep the existing capture active until
a new projection is acquired, and restore the
voice-call audio source when capture restarts.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): use JDK 17 in playground workflow

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): clear pending capture restart on denial

Notify MainService when a recovery projection
request is canceled so a later projection grant
cannot restart stale capture state.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): handle audio and projection recovery failures

Verify AudioRecord startup, propagate voice-call restoration failures,
and clear stale capture recovery state when projection setup fails.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-31 22:29:51 +08:00
rustdesk
2c84c8fb13 change to 3.44.9 flutter for arm 2026-08-31 19:06:32 +08:00
RustDesk
66ab0b87f6 Linux drop shell from service loop (#15979)
* perf(linux): stop the service loop from forking a shell per environment variable

The service loop re-derives the desktop every 500 ms, and every lookup on that
path forks. A healthy GNOME session spends ~104 process spawns a second, 8 full
`ps -u <uid>` scans and 2 full `ps aux` scans, to re-answer a question whose
answer has not changed. `get_env` alone is a `sh -c` pipeline of ~12 processes
per variable.

`get_envs` already reads `/proc` directly and was documented as the intended
replacement, so move the remaining `get_env` callers to it and delete it. The
xwayland probe drops from 4 pipelines (~48 processes) to one `/proc` walk, and
the pathological walk that #15952 was about drops from ~2900 processes to at
most 60 `/proc` walks. `get_cm` and `is_xwayland_running` read `/proc` instead
of forking `ps aux` and `pgrep -a`; `get_cm` also called `current_exe()` once
per line of `ps` output.

Selection semantics are preserved where they were load-bearing:

* `get_envs_of_newest` reproduces the `ps ... | tail -1` the removed pipelines
  used, so a variable the newest matching process does not have means moving on
  to the next pattern, never on to an older process that may belong to a session
  which has since logged out.
* `get_envs` keeps its own order (readdir) and its all-process ranking, so the
  existing `get_display_xauth_wayland` caller is unaffected. Only its handling
  of an exported-but-empty value changes: `DISPLAY=` no longer counts as found,
  where it used to satisfy a single-name query and return the empty value before
  a process holding a real one was examined.
* `get_envs_where` lets the caller state what a complete answer is. Ranking by
  how many of the requested names a process carries cannot know that `DISPLAY`
  is mandatory and the rest interchangeable, so it could rank a process holding
  three optional values above the one holding the pair that matters.

`is_xwayland_running` is scoped to the session's uid. The compositor starts
Xwayland as the session user, so another user's Xwayland -- a switched-away
session, a second seat -- used to route a pure-Wayland session into the Xwayland
probe, which has no display for it to find there.

Not addressed: this discovery path has never had any notion of the active
session, and filters by uid alone. Constraining candidates to the active session
is not possible for the most important one, since `xdg-desktop-portal` and its
backends run under `user@<uid>.service`, which spans sessions and carries no
`XDG_SESSION_ID`, no session cgroup and no audit sessionid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5egQpH4q4GoXJiuMoTJ5t

* fix(linux): the newest-process walk must not answer with a grep or an older PID

Three findings from review of the commit before this one.

`/proc/<pid>/environ` failing to read left the walk on to the next PID, which in
`newest_first` mode is an older process -- possibly of a session that has since
logged out -- where the `ps ... | tail -1` pipeline this replaces stopped at the
one PID it had already picked. A read that fails is a process carrying none of
the requested names, not a process to skip. The `seen` latch that was meant to
hold the newest process is deleted: `accept` is reached once per matching
process, so returning on the first is what it already did.

The regex is matched against the whole `/proc/<pid>/cmdline`, where the pipeline
had a `grep -v 'grep'`. A user running `grep Xwayland` is otherwise the newest
match for that pattern and answers with whatever environment their shell had --
an X forwarding endpoint over ssh, say. This is the one place the walk still
differs from the `get_envs` it grew out of, which never had that filter and
could take an ssh `grep` over the portal it was looking for.

`get_envs` is left exactly as it was. Its completeness test was every requested
name *present*; stating it through `accept` turned it into every name *non-empty*
and, with the empty-value change that went with it, moved which process the
existing `get_display_xauth_wayland` caller settles on. `accept` is now told the
count and asks the question the loop it replaced asked. This supersedes the
`get_envs` bullet of the previous commit message: an exported-but-empty value
counts as found again, as it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QgsYAUYKDei1AM5yHJsMX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:51:27 +08:00
rustdesk
169f74f8d9 fix(ci): check out submodules in update-webpki-roots
The root workspace lists libs/hbb_common as a member, so without the
submodule cargo cannot load the workspace and `cargo update` exits 101.
The job has failed on every scheduled run since it was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gecc6fgEeSxs6VRiQmAeof
2026-08-31 17:31:57 +08:00
Michael Clark
d4b06a6c5c fix: android: replace all-files access with scoped storage (#15602)
* fix: android: replace all-files access with scoped storage + system picker

Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and
WRITE_EXTERNAL_STORAGE from the Android manifest. Remove
requestLegacyExternalStorage. Replace broad external storage with
app-scoped external storage for the file-transfer workspace.

File import uses the system file_picker. File export uses Android's
SAF ACTION_CREATE_DOCUMENT with path validation that restricts
export sources to app-owned directories.

Remove the external_path dependency.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: refine file import feedback

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: use SAF for file imports

Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: keep file imports in the selected directory

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: reset projection and constrain file workspace

Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android, enforce file workspace boundary on the server, and unblock the ui thread.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix(android): harden scoped storage file handling

- create new SAF documents instead of overwriting export sources
- reject empty peer paths except for home directory reads
- report directory backup restore and cleanup failures
- resolve log export paths from the configured app name

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): harden scoped-storage file operations

- snapshot directory exports before writing to the destination
- query document provider metadata off the main thread
- reject invalid remote directories without read timeouts

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): handle SAF directory name collisions

- reject dot-segment folder names during import
- fail imports with duplicate document display names
- only reuse matching directories during export

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): handle SAF folder import collisions

Reject filesystem-equivalent destination names and
avoid showing a failure when folder overwrite is skipped.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-31 16:29:51 +08:00
fufesou
03a7fc5992 fix(flutter): align terminal shortcuts with platform conventions (#15970)
* fix(flutter): align terminal shortcuts with platform conventions

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(flutter): handle Linux terminal paste with modifier locks

Detect platform-specific paste shortcuts so Ctrl+Shift+V bypasses
virtual Ctrl/Alt modifiers on Linux. Add regression coverage.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-08-27 16:33:58 +08:00
111 changed files with 3692 additions and 450 deletions

View File

@@ -31,7 +31,7 @@ env:
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
FLUTTER_WINDOWS_ARM_VERSION: "3.44.9"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
@@ -44,7 +44,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.9"
VERSION: "1.5.0"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -389,6 +389,54 @@ jobs:
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Build pre-built MSI template
# Two things this works around: preprocess.py rewrites res/msi in place, so the
# tree is reset around this second variant; and it locates the app as
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
#
# The placeholder is chosen to keep this template as close to the shipped msi as
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
# derives no short name for it. A longer placeholder would get one, and a patch
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
#
# It still has to be unique, which is why "RustDesk" itself cannot be used:
# it also names payload that must never be renamed, such as librustdesk.dll
# and drivers\RustDeskPrinterDriver.
#
#
# Building the arm64 template on the native arm64 runner makes the ARM
# package available: the build agents are x64 and cannot run
# preprocess.py against an ARM exe.
if: env.UPLOAD_ARTIFACT == 'true'
run: |
git checkout -- res/msi
cp -r ./rustdesk ./rustdesk-msi-template
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
New-Item -ItemType Directory -Force -Path $assets | Out-Null
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
}
pushd ./res/msi
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
popd
mkdir ./msi-template
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
git checkout -- res/msi
rm -r -fo ./rustdesk-msi-template
- name: Upload unsigned msi template
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
path: ./msi-template
- name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
shell: bash
@@ -925,15 +973,33 @@ jobs:
name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-aarch64
path: ./windows-aarch64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86
path: ./windows-x86/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-x86_64
path: ./msi-template/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-aarch64
path: ./msi-template/
- name: Combine unsigned app
run: |
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
- name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
VERSION: "1.4.9"
VERSION: "1.5.0"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -283,7 +283,7 @@ jobs:
nasm \
yasm \
ninja-build \
openjdk-11-jdk-headless \
openjdk-17-jdk-headless \
pkg-config \
tree \
wget
@@ -365,9 +365,9 @@ jobs:
- name: Build rustdesk
shell: bash
env:
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: |
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
# temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in

View File

@@ -33,6 +33,10 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# The root workspace lists libs/hbb_common as a member; without the
# submodule its manifest is missing and cargo cannot load the workspace.
submodules: recursive
- name: Update webpki-roots in all lockfiles
id: update

4
Cargo.lock generated
View File

@@ -7177,7 +7177,7 @@ dependencies = [
[[package]]
name = "rustdesk"
version = "1.4.9"
version = "1.5.0"
dependencies = [
"android-wakelock",
"android_logger",
@@ -7287,7 +7287,7 @@ dependencies = [
[[package]]
name = "rustdesk-portable-packer"
version = "1.4.9"
version = "1.5.0"
dependencies = [
"brotli",
"dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.9"
version = "1.5.0"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.5.0
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.5.0
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -82,7 +82,8 @@ protobuf {
}
android {
compileSdkVersion 34
namespace "com.carriez.flutter_hbb"
compileSdkVersion 36
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
@@ -91,6 +92,7 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8
}
@@ -99,7 +101,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.carriez.flutter_hbb"
minSdkVersion 22
targetSdkVersion 33
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@@ -128,6 +130,7 @@ flutter {
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5'

View File

@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.carriez.flutter_hbb">
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -26,7 +30,6 @@
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:label="RustDesk"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true">
@@ -88,7 +91,12 @@
<service
android:name=".MainService"
android:enabled="true"
android:foregroundServiceType="mediaProjection" />
android:exported="false"
android:foregroundServiceType="specialUse|mediaProjection|microphone">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="@string/foreground_service_special_use_subtype" />
</service>
<service
android:name=".FloatingWindowService"

View File

@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
companion object {
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
private const val NO_ACTIVE_PUBLISHERS = 0
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
@Synchronized
private fun acquireAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", true)
}
activeAudioFramePublishers++
}
@Synchronized
private fun releaseAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
Log.e(LOG_TAG, "No active audio frame publisher to release")
return
}
activeAudioFramePublishers--
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", false)
}
}
}
private val logTag = LOG_TAG
private var audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return
}
// read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK,
AUDIO_ENCODING
)
if (minBufferSize == 0) {
if (bufferSize <= 0) {
Log.d(logTag, "get min buffer size fail!")
return
}
audioReader = AudioReader(minBufferSize, 4)
audioReader = AudioReader(bufferSize, 4)
minBufferSize = bufferSize
Log.d(logTag, "init audioData len:$minBufferSize")
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder() {
checkAudioReader()
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
try {
FFI.setFrameRawEnable("audio", true)
audioRecorder!!.startRecording()
audioRecordStat = true
audioThread = thread {
while (audioRecordStat) {
audioReader!!.readSync(audioRecorder!!)?.let {
FFI.onAudioFrameUpdate(it)
}
}
// let's release here rather than onDestroy to avoid threading issue
audioRecorder?.release()
audioRecorder = null
minBufferSize = 0
FFI.setFrameRawEnable("audio", false)
Log.d(logTag, "Exit audio thread")
}
} catch (e: Exception) {
Log.d(logTag, "startAudioRecorder fail:$e")
private fun releaseRecorder(recorder: AudioRecord) {
try {
recorder.release()
} finally {
if (audioRecorder === recorder) {
audioRecorder = null
}
} else {
Log.d(logTag, "startAudioRecorder fail")
}
}
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
try {
while (audioRecordStat) {
reader.readSync(recorder)?.let {
FFI.onAudioFrameUpdate(it)
}
}
} finally {
minBufferSize = 0
try {
releaseRecorder(recorder)
} finally {
releaseAudioFramePublisher()
Log.d(logTag, "Exit audio thread")
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder(): Boolean {
val recorder = audioRecorder
if (recorder == null) {
Log.d(logTag, "startAudioRecorder fail")
return false
}
var audioFramePublisherAcquired = false
return try {
checkAudioReader()
val reader = audioReader
if (reader == null || minBufferSize == 0) {
releaseRecorder(recorder)
Log.d(logTag, "startAudioRecorder fail")
return false
}
recorder.startRecording()
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
throw IllegalStateException("AudioRecord failed to enter recording state")
}
audioRecordStat = true
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
acquireAudioFramePublisher()
audioFramePublisherAcquired = true
audioThread = captureThread
captureThread.start()
true
} catch (error: Exception) {
audioRecordStat = false
audioThread = null
Log.e(logTag, "startAudioRecorder fail", error)
try {
releaseRecorder(recorder)
} finally {
if (audioFramePublisherAcquired) {
releaseAudioFramePublisher()
}
}
false
}
}
fun isVoiceCallActive(): Boolean {
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) {
return false
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
if (!isSupportVoiceCall()) {
return true
}
if (isVideoStart()) {
switchOutVoiceCall(mediaProjection)
}
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
tryReleaseAudio()
return true
return switched
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
fun tryReleaseAudio() {

View File

@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
import ffi.FFI
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
import android.media.MediaCodecList
import android.media.MediaFormat
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.util.DisplayMetrics
import androidx.annotation.RequiresApi
import org.json.JSONArray
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlin.concurrent.thread
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
private val channelTag = "mChannel"
private val logTag = "mMainActivity"
private var mainService: MainService? = null
private sealed class PendingPicker {
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
data class ExportFiles(
val sources: List<File>,
val rejected: Int,
val result: MethodChannel.Result
) : PendingPicker()
}
private data class ExportSource(
val file: File,
val children: List<ExportSource>?
)
private var pendingPicker: PendingPicker? = null
private var isAudioStart = false
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_IMPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
pendingPicker = null
if (resultCode != Activity.RESULT_OK || data == null) {
pending.result.success(emptyList<Map<String, String>>())
return
}
val uris = linkedSetOf<Uri>()
data.data?.let { uris.add(it) }
data.clipData?.let { clipData ->
for (index in 0 until clipData.itemCount) {
uris.add(clipData.getItemAt(index).uri)
}
}
thread {
val files = uris.map { uri ->
mapOf(
"uri" to uri.toString(),
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
)
}
runOnUiThread { pending.result.success(files) }
}
return
}
if (requestCode == REQ_EXPORT_FILE) {
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
pendingPicker = null
val destination = data?.data
if (resultCode != Activity.RESULT_OK || destination == null) {
pending.result.success(false)
return
}
thread {
try {
FileInputStream(pending.source).use { input ->
contentResolver.openOutputStream(destination, "wt")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("Unable to open the selected destination")
}
runOnUiThread { pending.result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to export file", e)
runOnUiThread {
pending.result.error("export_failed", e.message, null)
}
}
}
return
}
if (requestCode == REQ_IMPORT_DIRECTORY) {
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
val selected = mapOf(
"uri" to treeUri.toString(),
"name" to (treeDisplayName(treeUri) ?: "Imported")
)
runOnUiThread { pending.result.success(selected) }
}
return
}
if (requestCode == REQ_EXPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
var exported = 0
var failed = pending.rejected
var processed = 0
try {
val sources = pending.sources.map { snapshotExportSource(it) }
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
sources.forEach { source ->
val ok = source?.let {
copyExportSourceToTree(treeUri, rootDocId, it)
} ?: false
if (ok) exported++ else failed++
processed++
}
} catch (e: Exception) {
Log.e(logTag, "Failed to export selected files", e)
failed += pending.sources.size - processed
}
runOnUiThread {
pending.result.success(mapOf("exported" to exported, "failed" to failed))
}
}
return
}
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
}
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
result.success(false)
}
}
PICK_IMPORT_FILES -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportFiles(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
},
REQ_IMPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_FILE -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.isDirectory ||
destination.parentFile?.isDirectory != true) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var reservedDestination = false
var errorCode = "import_failed"
try {
val temporaryFile = File.createTempFile(
".rustdesk-import-",
".tmp",
destination.parentFile
)
temporary = temporaryFile
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(temporaryFile).use { output ->
input.copyTo(output)
}
} ?: throw IllegalStateException("Unable to open the selected document")
if (!overwrite) {
reservedDestination = destination.createNewFile()
if (!reservedDestination) {
throw IllegalStateException("The destination already exists")
}
}
if (!temporaryFile.renameTo(destination)) {
if (reservedDestination) {
destination.delete()
}
errorCode = "rename_failed"
throw IllegalStateException("Unable to replace the destination")
}
runOnUiThread { result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to import file", e)
runOnUiThread {
result.error(errorCode, e.message, null)
}
} finally {
temporary?.delete()
}
}
}
}
EXPORT_FILE -> {
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
val source = path?.let { canonicalExportSource(it) }
if (source?.isFile != true) {
result.error("invalid_source", "The file is outside app-scoped storage", null)
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(source.extension.lowercase())
?: "application/octet-stream"
pendingPicker = PendingPicker.ExportFile(source, result)
try {
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, source.name)
},
REQ_EXPORT_FILE
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
PICK_IMPORT_DIRECTORY -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportDirectory(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
},
REQ_IMPORT_DIRECTORY
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_DIRECTORY -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.parentFile?.isDirectory != true ||
(destination.exists() && !destination.isDirectory)) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var backup: File? = null
val ok = try {
val parent = destination.parentFile
?: throw IllegalStateException("The destination has no parent")
temporary = File.createTempFile(
".rustdesk-import-dir-",
".tmp",
parent
).also {
if (!it.delete() || !it.mkdir()) {
throw IllegalStateException("Unable to create a temporary folder")
}
}
if (!copyDocumentTreeToFile(uri, temporary!!)) {
throw IllegalStateException("Unable to read all folder contents")
}
if (destination.exists()) {
if (!overwrite) {
throw IllegalStateException("The destination already exists")
}
val backupFile = File.createTempFile(
".rustdesk-import-backup-",
".tmp",
parent
)
if (!backupFile.delete()) {
throw IllegalStateException("Unable to prepare the destination backup")
}
backup = backupFile
if (!destination.renameTo(backupFile)) {
throw IllegalStateException("Unable to replace the destination")
}
}
if (!temporary!!.renameTo(destination)) {
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.renameTo(destination)
) {
throw IllegalStateException(
"Unable to move the imported folder and restore " +
"the destination from $destinationBackup"
)
}
throw IllegalStateException("Unable to move the imported folder")
}
temporary = null
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.deleteRecursively()
) {
throw IllegalStateException(
"Unable to remove the destination backup: $destinationBackup"
)
}
backup = null
true
} catch (e: Exception) {
Log.e(logTag, "Failed to import directory", e)
false
} finally {
temporary?.deleteRecursively()
}
runOnUiThread { result.success(ok) }
}
}
}
EXPORT_FILES -> {
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
if (paths.isNullOrEmpty()) {
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
} else {
val sources = paths.mapNotNull {
(it as? String)?.let(::canonicalExportSource)
}
val rejected = paths.size - sources.size
if (sources.isEmpty()) {
result.success(mapOf("exported" to 0, "failed" to rejected))
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
},
REQ_EXPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
}
GET_VALUE -> {
if (call.arguments is String) {
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
}
}
private fun canonicalAppScopedFile(path: String): File? {
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
runCatching { it.canonicalFile }.getOrNull()
}
return file.takeIf { candidate ->
allowedRoots.any { root ->
candidate == root || candidate.path.startsWith(root.path + File.separator)
}
}
}
private fun canonicalExportSource(path: String): File? {
val original = File(path).absoluteFile
val canonical = canonicalAppScopedFile(path) ?: return null
return canonical.takeIf {
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
}
}
private fun snapshotExportSource(source: File): ExportSource? {
val safeSource = canonicalExportSource(source.path) ?: return null
if (safeSource.isFile) return ExportSource(safeSource, null)
val sourceChildren = safeSource.listFiles() ?: return null
val children = ArrayList<ExportSource>(sourceChildren.size)
for (child in sourceChildren) {
val snapshot = snapshotExportSource(child) ?: return null
children.add(snapshot)
}
return ExportSource(safeSource, children)
}
private fun copyExportSourceToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children
return if (children == null) {
copyFileToTree(treeUri, parentDocId, source.file)
} else {
copyDirToTree(treeUri, parentDocId, source)
}
}
private fun treeDisplayName(treeUri: Uri): String? {
return try {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
contentResolver.query(
docUri,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected folder name", e)
null
}
}
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
}
private fun copyChildrenToFile(
treeUri: Uri,
parentDocId: String,
destinationDir: File
): Boolean {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
var ok = true
val destinationNames = HashSet<String>()
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: return false
cursor.use {
while (cursor.moveToNext()) {
val docId = cursor.getString(0)
val name = cursor.getString(1)
val mime = cursor.getString(2)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
if (name != null && !destinationNames.add(name)) {
ok = false
continue
}
val destination = safeDestinationChild(destinationDir, name)
if (destination == null || destination.exists()) {
ok = false
continue
}
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!destination.mkdirs() && !destination.isDirectory) {
ok = false
continue
}
if (!copyChildrenToFile(treeUri, docId, destination)) {
ok = false
}
} else if (!copyDocumentToFile(docUri, destination)) {
ok = false
}
}
}
return ok
}
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
if (name.isNullOrEmpty() || name == "." || name == ".." ||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
return null
}
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
}
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
return try {
destination.parentFile?.mkdirs()
if (destination.exists() && !destination.delete()) {
return false
}
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) }
} != null
} catch (e: Exception) {
Log.e(logTag, "Failed to copy document to $destination", e)
false
}
}
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
return try {
val mime = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(safeSource.extension.lowercase())
?: "application/octet-stream"
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
val docUri = DocumentsContract.createDocument(
contentResolver,
parentUri,
mime,
safeSource.name
) ?: return false
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
FileInputStream(safeSource).use { input -> input.copyTo(output) }
} ?: return false
true
} catch (e: Exception) {
Log.e(logTag, "Failed to export file $safeSource", e)
false
}
}
private fun copyDirToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children ?: return false
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
?: return false
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
if (dirDocId == null) {
dirDocId = try {
DocumentsContract.createDocument(
contentResolver,
parentUri,
DocumentsContract.Document.MIME_TYPE_DIR,
safeSource.name
)?.let { DocumentsContract.getDocumentId(it) }
} catch (e: Exception) {
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
null
}
}
if (dirDocId == null) return false
var ok = true
children.forEach { child ->
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
if (!childOk) ok = false
}
return ok
}
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: throw IllegalStateException("Unable to query destination folder")
cursor.use {
while (cursor.moveToNext()) {
if (cursor.getString(1) == name &&
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
) {
return cursor.getString(0)
}
}
}
return null
}
private val childColumns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
private fun displayName(uri: Uri): String? {
return try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected document name", e)
null
}
}
private fun setCodecInfo() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecs = codecList.codecInfos

View File

@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.graphics.Color
@@ -150,7 +151,7 @@ class MainService : Service() {
if (incomingVoiceCall) {
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
} else {
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
if (!switchOutVoiceCall()) {
Log.e(logTag, "switchOutVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
@@ -159,7 +160,7 @@ class MainService : Service() {
}
}
} else {
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
if (!switchToVoiceCall()) {
Log.e(logTag, "switchToVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
@@ -214,6 +215,19 @@ class MainService : Service() {
// video
private var mediaProjection: MediaProjection? = null
private var mediaProjectionCallback: MediaProjection.Callback? = null
private var captureRestartPending = false
private var captureRestartInVoiceCall = false
private val mediaProjectionResultReceiver =
object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == RES_FAILED) {
cancelMediaProjectionRecovery()
}
}
}
private var mediaProjectionForegroundService = false
private var microphoneForegroundService = false
private var surface: Surface? = null
private val sendVP9Thread = Executors.newSingleThreadExecutor()
private var videoEncoder: MediaCodec? = null
@@ -243,7 +257,9 @@ class MainService : Service() {
// keep the config dir same with flutter
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
FFI.startServer(configPath, "")
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
?: applicationContext.filesDir.absolutePath
FFI.startServer(configPath, homePath, "")
createForegroundNotification()
}
@@ -337,8 +353,6 @@ class MainService : Service() {
Log.d("whichService", "this service: ${Thread.currentThread()}")
super.onStartCommand(intent, flags, startId)
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
createForegroundNotification()
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
FFI.startService()
}
@@ -347,10 +361,7 @@ class MainService : Service() {
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
mediaProjection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
checkMediaPermission()
_isReady = true
replaceMediaProjection(mediaProjectionManager, it)
} ?: let {
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
requestMediaProjection()
@@ -364,14 +375,23 @@ class MainService : Service() {
updateScreenInfo(newConfig.orientation)
}
private fun requestMediaProjection() {
private fun requestMediaProjection(recovery: Boolean = false) {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION
flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (recovery) {
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
}
}
startActivity(intent)
}
@Synchronized
private fun cancelMediaProjectionRecovery() {
captureRestartPending = false
captureRestartInVoiceCall = false
}
@SuppressLint("WrongConstant")
private fun createSurface(): Surface? {
return if (useVP9) {
@@ -405,15 +425,149 @@ class MainService : Service() {
}
}
fun onVoiceCallStarted(): Boolean {
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
private fun releaseMediaProjection() {
val projection = mediaProjection
val callback = mediaProjectionCallback
mediaProjection = null
mediaProjectionCallback = null
if (projection != null && callback != null) {
projection.unregisterCallback(callback)
}
projection?.stop()
}
@Synchronized
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
if (mediaProjection !== stoppedProjection) {
return
}
Log.d(logTag, "MediaProjection stopped")
setMediaProjectionForegroundService(false)
stopCapture()
virtualDisplay?.release()
virtualDisplay = null
mediaProjection = null
mediaProjectionCallback = null
_isReady = false
checkMediaPermission()
}
@Synchronized
private fun replaceMediaProjection(
mediaProjectionManager: MediaProjectionManager,
resultIntent: Intent,
) {
val wasCapturing = isStart
val restartCapture = wasCapturing || captureRestartPending
val restartInVoiceCall = if (wasCapturing) {
audioRecordHandle.isVoiceCallActive()
} else {
captureRestartInVoiceCall
}
val hadProjection = mediaProjection != null
if (!setMediaProjectionForegroundService(true)) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
checkMediaPermission()
}
return
}
val projection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
if (projection == null) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
setMediaProjectionForegroundService(false)
checkMediaPermission()
}
return
}
if (wasCapturing) {
stopCapture()
}
captureRestartPending = restartCapture
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
val callback = object : MediaProjection.Callback() {
override fun onStop() {
handleMediaProjectionStopped(projection)
}
}
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
mediaProjection = projection
mediaProjectionCallback = callback
_isReady = true
checkMediaPermission()
if (restartCapture) {
captureRestartPending = false
startCapture(restartInVoiceCall)
}
}
@Synchronized
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
if (!setMicrophoneForegroundService(true)) {
return false
}
if (startAudio()) {
return true
}
setMicrophoneForegroundService(false)
return false
}
@Synchronized
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
val stopped = stopAudio()
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return stopped && foregroundServiceUpdated
}
@Synchronized
private fun switchToVoiceCall(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.switchToVoiceCall(mediaProjection)
}
}
@Synchronized
private fun switchOutVoiceCall(): Boolean {
captureRestartInVoiceCall = false
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return switched && foregroundServiceUpdated
}
@Synchronized
fun onVoiceCallStarted(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.onVoiceCallStarted(mediaProjection)
}
}
@Synchronized
fun onVoiceCallClosed(): Boolean {
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
captureRestartInVoiceCall = false
return stopMicrophoneCapture {
audioRecordHandle.onVoiceCallClosed(mediaProjection)
}
}
fun startCapture(): Boolean {
return startCapture(false)
}
@Synchronized
private fun startCapture(inVoiceCall: Boolean): Boolean {
if (isStart) {
return true
}
@@ -421,25 +575,35 @@ class MainService : Service() {
Log.w(logTag, "startCapture fail,mediaProjection is null")
return false
}
captureRestartInVoiceCall = inVoiceCall
updateScreenInfo(resources.configuration.orientation)
Log.d(logTag, "Start Capture")
surface = createSurface()
if (useVP9) {
val videoStarted = if (useVP9) {
startVP9VideoRecorder(mediaProjection!!)
} else {
startRawVideoRecorder(mediaProjection!!)
}
if (!videoStarted) {
if (!captureRestartPending) {
captureRestartInVoiceCall = false
}
releaseFailedVideoCapture()
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
Log.d(logTag, "createAudioRecorder fail")
val audioStarted = if (inVoiceCall) {
switchToVoiceCall()
} else {
Log.d(logTag, "audio recorder start")
audioRecordHandle.startAudioRecorder()
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
audioRecordHandle.startAudioRecorder()
}
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
}
captureRestartInVoiceCall = false
checkMediaPermission()
_isStart = true
FFI.setFrameRawEnable("video",true)
@@ -447,9 +611,24 @@ class MainService : Service() {
return true
}
private fun releaseFailedVideoCapture() {
imageReader?.close()
imageReader = null
videoEncoder?.let {
it.signalEndOfInputStream()
it.stop()
it.release()
}
videoEncoder = null
surface?.release()
surface = null
}
@Synchronized
fun stopCapture() {
Log.d(logTag, "Stop Capture")
captureRestartPending = false
captureRestartInVoiceCall = false
FFI.setFrameRawEnable("video",false)
_isStart = false
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
@@ -480,8 +659,11 @@ class MainService : Service() {
surface?.release()
// release audio
_isAudioStart = false
audioRecordHandle.tryReleaseAudio()
stopMicrophoneCapture {
_isAudioStart = false
audioRecordHandle.tryReleaseAudio()
true
}
}
fun destroy() {
@@ -496,7 +678,9 @@ class MainService : Service() {
virtualDisplay = null
}
mediaProjection = null
releaseMediaProjection()
mediaProjectionForegroundService = false
microphoneForegroundService = false
checkMediaPermission()
stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java))
@@ -519,49 +703,70 @@ class MainService : Service() {
return isReady
}
private fun startRawVideoRecorder(mp: MediaProjection) {
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
if (surface == null) {
val captureSurface = surface
if (captureSurface == null) {
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
return
return false
}
createOrSetVirtualDisplay(mp, surface!!)
return createOrSetVirtualDisplay(mp, captureSurface)
}
private fun startVP9VideoRecorder(mp: MediaProjection) {
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
createMediaCodec()
videoEncoder?.let {
surface = it.createInputSurface()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
}
it.setCallback(cb)
it.start()
createOrSetVirtualDisplay(mp, surface!!)
val encoder = videoEncoder ?: return false
val inputSurface = encoder.createInputSurface()
surface = inputSurface
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
}
encoder.setCallback(cb)
encoder.start()
return createOrSetVirtualDisplay(mp, inputSurface)
}
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
try {
virtualDisplay?.let {
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
it.setSurface(s)
} ?: let {
virtualDisplay = mp.createVirtualDisplay(
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
return try {
val existingDisplay = virtualDisplay
if (existingDisplay != null) {
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
existingDisplay.setSurface(s)
true
} else {
val display = mp.createVirtualDisplay(
"RustDeskVD",
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
s, null, null
)
if (display == null) {
Log.e(logTag, "createOrSetVirtualDisplay failed")
handleVirtualDisplayFailure()
} else {
virtualDisplay = display
true
}
}
} catch (e: SecurityException) {
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
// This initiates a prompt dialog for the user to confirm screen projection.
requestMediaProjection()
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
handleVirtualDisplayFailure()
}
}
private fun handleVirtualDisplayFailure(): Boolean {
captureRestartPending = true
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
setMediaProjectionForegroundService(false)
_isReady = false
checkMediaPermission()
requestMediaProjection(true)
return false
}
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
@@ -652,7 +857,63 @@ class MainService : Service() {
.setColor(ContextCompat.getColor(this, R.color.primary))
.setWhen(System.currentTimeMillis())
.build()
startForeground(DEFAULT_NOTIFY_ID, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
} else {
startForeground(DEFAULT_NOTIFY_ID, notification)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun foregroundServiceType(): Int {
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
}
if (mediaProjectionForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return serviceType
}
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
}
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
}
private fun updateForegroundServiceTypes(
mediaProjectionEnabled: Boolean,
microphoneEnabled: Boolean,
): Boolean {
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
microphoneForegroundService == microphoneEnabled) {
return true
}
val previousMediaProjection = mediaProjectionForegroundService
val previousMicrophone = microphoneForegroundService
mediaProjectionForegroundService = mediaProjectionEnabled
microphoneForegroundService = microphoneEnabled
return try {
createForegroundNotification()
true
} catch (error: SecurityException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
} catch (error: IllegalStateException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
}
}
private fun loginRequestNotification(

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log
class PermissionRequestTransparentActivity: Activity() {
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
if (resultCode == RESULT_OK && data != null) {
launchService(data)
} else {
setResult(RES_FAILED)
val resultReceiver =
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
if (resultReceiver != null) {
resultReceiver.send(RES_FAILED, null)
} else {
setResult(RES_FAILED)
}
}
}
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
}
}
}
}

View File

@@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
const val REQ_REQUEST_MEDIA_PROJECTION = 201
const val REQ_EXPORT_FILE = 301
const val REQ_IMPORT_FILES = 302
const val REQ_IMPORT_DIRECTORY = 303
const val REQ_EXPORT_FILES = 304
// Activity responseCode
const val RES_FAILED = -100
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
const val PICK_IMPORT_FILES = "pick_import_files"
const val IMPORT_FILE = "import_file"
const val EXPORT_FILE = "export_file"
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
const val IMPORT_DIRECTORY = "import_directory"
const val EXPORT_FILES = "export_files"
const val GET_VALUE = "get_value"
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
@@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}
}

View File

@@ -15,7 +15,7 @@ object FFI {
external fun init(ctx: Context)
external fun onAppStart(ctx: Context)
external fun setClipboardManager(clipboardManager: RdClipboardManager)
external fun startServer(app_dir: String, custom_client_config: String)
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
external fun startService()
external fun onVideoFrameUpdate(buf: ByteBuffer)
external fun onAudioFrameUpdate(buf: ByteBuffer)

View File

@@ -1,4 +1,5 @@
<resources>
<string name="app_name">RustDesk</string>
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
</resources>

View File

@@ -1,3 +1,29 @@
def legacyPluginNamespaces = [
external_path: 'com.pinciat.external_path',
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
sqflite: 'com.tekartik.sqflite',
uni_links: 'name.avioli.unilinks',
]
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
def java8KotlinJvmTargets = [
app: java8JvmTarget,
external_path: java8JvmTarget,
qr_code_scanner: java8JvmTarget,
]
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
project.plugins.withId('kotlin-android') {
project.tasks.configureEach { task ->
if (!task.hasProperty('kotlinOptions')) {
return
}
task.kotlinOptions.jvmTarget = kotlinJvmTarget
}
}
}
allprojects {
repositories {
google()
@@ -9,6 +35,16 @@ allprojects {
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
def legacyNamespace = legacyPluginNamespaces[project.name]
if (legacyNamespace != null) {
project.plugins.withId('com.android.library') {
project.android.namespace = legacyNamespace
}
}
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
if (kotlinJvmTarget != null) {
configureKotlinJvmTarget(project, kotlinJvmTarget)
}
}
subprojects {
project.evaluationDependsOn(':app')

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.1" apply false
id "com.android.application" version "8.10.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
}

View File

@@ -1519,13 +1519,6 @@ class AndroidPermissionManager {
static Timer? _timer;
static var _current = "";
static bool isWaitingFile() {
if (_completer != null) {
return !_completer!.isCompleted && _current == kManageExternalStorage;
}
return false;
}
static Future<bool> check(String type) {
if (isDesktop || isWeb) {
return Future.value(true);
@@ -2634,13 +2627,6 @@ connect(BuildContext context, String id,
}
} else {
if (isFileTransfer) {
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
}
}
}
if (isWeb) {
Navigator.push(
context,

View File

@@ -168,6 +168,8 @@ const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableUdpPunch = "enable-udp-punch";
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
const String kOptionAllowSyncClipboardBetweenSessions =
"allow-sync-clipboard-between-sessions";
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
@@ -439,7 +441,6 @@ const kActionApplicationDetailsSettings =
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
const kRecordAudio = "android.permission.RECORD_AUDIO";
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
const kRequestIgnoreBatteryOptimizations =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
@@ -451,6 +452,12 @@ class AndroidChannel {
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
static final kSyncAppDirConfigPath = "sync_app_dir";
static final kPickImportFiles = "pick_import_files";
static final kImportFile = "import_file";
static final kExportFile = "export_file";
static final kPickImportDirectory = "pick_import_directory";
static final kImportDirectory = "import_directory";
static final kExportFiles = "export_files";
}
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels

View File

@@ -575,6 +575,15 @@ class _GeneralState extends State<_General> {
kOptionEnableIpv6Punch,
isServer: false,
),
Tooltip(
message: translate('sync-clipboard-between-sessions-tip'),
child: _OptionCheckBox(
context,
'Sync clipboard between sessions',
kOptionAllowSyncClipboardBetweenSessions,
isServer: false,
),
),
],
];

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../consts.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
gFFI.ffiModel.beginAndroidDocumentPicker();
try {
return await action();
} finally {
gFFI.ffiModel.endAndroidDocumentPicker();
}
}
Future<void> _importFiles() async {
var imported = 0;
var failed = false;
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final selectedFiles = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<List<dynamic>>(
AndroidChannel.kPickImportFiles));
if (selectedFiles == null || selectedFiles.isEmpty) return;
for (final selected in selectedFiles) {
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
final selectedName = selected['name'] as String?;
final name = selectedName?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
!PathUtil.validName(name, importIsWindows)) {
failed = true;
continue;
}
final destination =
PathUtil.join(importDirectory, name, importIsWindows);
var overwrite = false;
if (await File(destination).exists()) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult == false) break;
if (overwriteResult != true) continue;
overwrite = true;
}
try {
final success = await gFFI.invokeMethod(
AndroidChannel.kImportFile,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
imported++;
} else {
failed = true;
}
} catch (e) {
failed = true;
debugPrint('Failed to import $name: $e');
}
}
} catch (e) {
failed = true;
debugPrint('Failed to select files for import: $e');
}
await importController.refresh();
if (failed) {
showToast(translate('Failed'));
} else if (imported > 0) {
showToast(translate('Successful'));
}
}
Future<void> _exportFile(Entry entry) async {
try {
final exported = await _runAndroidDocumentPicker(() => gFFI
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
if (exported == true) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export ${entry.name}: $e');
showToast(translate('Failed'));
}
}
Future<void> _importFolder() async {
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final picked = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kPickImportDirectory));
if (picked == null || picked.isEmpty) return;
final uri = picked['uri'] as String?;
final name =
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
name == '.' ||
name == '..' ||
!PathUtil.validName(name, importIsWindows)) {
showToast(translate('Failed'));
return;
}
final destination = PathUtil.join(importDirectory, name, importIsWindows);
final destinationType = await FileSystemEntity.type(destination);
var overwrite = false;
if (destinationType == FileSystemEntityType.directory) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult != true) return;
overwrite = true;
} else if (destinationType != FileSystemEntityType.notFound) {
showToast(translate('Failed'));
return;
}
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
showToast(translate('Successful'));
} else {
showToast(translate('Failed'));
}
} catch (e) {
debugPrint('Failed to import folder: $e');
showToast(translate('Failed'));
}
await importController.refresh();
}
Future<void> _exportItems(SelectedItems items) async {
await _exportPaths(items.items.map((e) => e.path));
}
Future<void> _exportLogs() async {
final home = currentFileController.homePath;
if (home.isEmpty) {
showToast(translate('Failed'));
return;
}
final appDir = PathUtil.join(home, appName, false);
final paths = [
PathUtil.join(appDir, 'Logs', false),
PathUtil.join(appDir, 'ScreenRecord', false),
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
if (paths.isEmpty) {
showToast(translate('Failed'));
return;
}
await _exportPaths(paths);
}
Future<void> _exportPaths(Iterable<String> paths) async {
try {
final result = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
if (result == null) return;
final exported = result['exported'] as int? ?? 0;
final failed = result['failed'] as int? ?? 0;
if (failed > 0) {
showToast(translate('Failed'));
} else if (exported > 0) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export paths: $e');
showToast(translate('Failed'));
}
}
@override
void initState() {
super.initState();
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
),
value: "refresh",
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import",
child: Row(
children: [
Icon(Icons.add_to_drive,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Add"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import_folder",
child: Row(
children: [
Icon(Icons.create_new_folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Import Folder"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "export_logs",
child: Row(
children: [
Icon(Icons.article_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Export Logs"))
],
),
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
onSelected: (v) {
if (v == "refresh") {
currentFileController.refresh();
} else if (v == "import") {
_importFiles();
} else if (v == "import_folder") {
_importFolder();
} else if (v == "export_logs") {
_exportLogs();
} else if (v == "select") {
model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear();
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {});
},
actions: [
if (isAndroid &&
selectedItems?.isLocal == true &&
selectedItems?.items.isNotEmpty == true) ...[
if (selectedItems!.items.length == 1 &&
selectedItems!.items.single.isFile)
IconButton(
tooltip: translate("Save as"),
icon: Icon(Icons.save_alt),
onPressed: () =>
_exportFile(selectedItems!.items.single),
)
else
IconButton(
tooltip: translate("Export"),
icon: Icon(Icons.drive_folder_upload),
onPressed: () => _exportItems(selectedItems!),
),
],
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),

View File

@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
void checkService() async {
gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
}
class ServiceNotRunningNotification extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,6 +9,7 @@ import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/input_modifier_utils.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_hbb/web/dummy.dart'
@@ -190,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
platform: defaultTargetPlatform,
logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent,
@@ -244,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
//
// Android works fine without this workaround.
deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(
_terminalModel.terminal,
_terminalModel.terminalController,
fallback: _handleTerminalKeyEvent,
),
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;

View File

@@ -381,6 +381,14 @@ class FileController {
void set homePath(String path) => options.value.home = path;
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
bool _isPathAllowed(String candidate) {
if (!isAndroid || !isLocal) return true;
if (homePath.isEmpty || candidate.isEmpty) return false;
final home = PathUtil.posixContext.normalize(homePath);
final target = PathUtil.posixContext.normalize(candidate);
return target == home || PathUtil.posixContext.isWithin(home, target);
}
String get shortPath {
final dirPath = directory.value.path;
if (dirPath.startsWith(homePath)) {
@@ -414,8 +422,13 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100));
final savedDir = (await bind.sessionGetPeerOption(
var savedDir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
savedDir = options.value.home;
await bind.sessionPeerOption(
sessionId: sessionId, name: "local_dir", value: savedDir);
}
Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path,
@@ -485,6 +498,9 @@ class FileController {
}
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!_isPathAllowed(path)) {
return false;
}
if (!isBack) {
pushHistory();
}
@@ -504,6 +520,7 @@ class FileController {
return true;
}
fd.format(isWindows, sort: sortBy.value);
selectedItems.reconcile(fd.entries);
directory.value = fd;
return true;
} catch (e) {
@@ -550,6 +567,9 @@ class FileController {
final isWindows = options.value.isWindows;
final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows);
if (!_isPathAllowed(parent)) {
return true;
}
// specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) {
return await _openDirectoryPath('/', isBack: isBack);
@@ -1885,7 +1905,7 @@ class PathUtil {
}
static bool validName(String name, bool isWindows) {
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
return reg.hasMatch(name);
@@ -1928,6 +1948,21 @@ class SelectedItems {
items.clear();
}
void reconcile(List<Entry> entries) {
if (items.isEmpty) return;
final currentByPath = {for (final entry in entries) entry.path: entry};
final reconciled = <Entry>[];
for (final item in items) {
final current = currentByPath[item.path];
if (current != null && current.entryType == item.entryType) {
reconciled.add(current);
}
}
items
..clear()
..addAll(reconciled);
}
void selectAll(List<Entry> entries) {
items.clear();
items.addAll(entries);

View File

@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
///
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
/// one-character paste as normal text when bracketed paste mode is disabled.
/// xterm already handles each platform's paste shortcut in the common case.
/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
/// emit a one-character paste as normal text when bracketed paste mode is off.
bool shouldHandleTerminalPasteShortcut({
required TargetPlatform platform,
required LogicalKeyboardKey logicalKey,
required bool isKeyDown,
required bool isKeyRepeat,
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed || shiftPressed) return false;
return controlPressed != metaPressed;
if (altPressed) return false;
switch (platform) {
case TargetPlatform.linux:
return controlPressed && !metaPressed && shiftPressed;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return !controlPressed && metaPressed && !shiftPressed;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
return controlPressed && !metaPressed && !shiftPressed;
}
}
/// Returns true when collapsing Row3 should also clear hidden modifier state.

View File

@@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier {
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _androidDocumentPickerActive = false;
bool _androidDocumentPickerInterruptedConnection = false;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference<FFI> parent;
@@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier {
_inputBlocked = false;
_timer?.cancel();
_timer = null;
_androidDocumentPickerActive = false;
_androidDocumentPickerInterruptedConnection = false;
resetRestartReconnectState();
clearPermissions();
waitForImageTimer?.cancel();
@@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier {
final text = evt['text'];
final link = evt['link'];
if (isAndroid &&
_androidDocumentPickerActive &&
title == 'Connection Error') {
_androidDocumentPickerInterruptedConnection = true;
return;
}
// Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck.
@@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier {
_restartReconnectDelayTimer = null;
}
void beginAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = true;
_androidDocumentPickerInterruptedConnection = false;
}
void endAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = false;
if (!_androidDocumentPickerInterruptedConnection ||
parent.target?.closed == true) {
return;
}
_androidDocumentPickerInterruptedConnection = false;
reconnect(parent.target!.dialogManager, sessionId, false);
}
/// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline(
@@ -4060,6 +4088,11 @@ class FFI {
return await platformFFI.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
}
// Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:external_path/external_path.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -171,8 +170,10 @@ class PlatformFFI {
_startListenEvent(_ffiBind); // global event
try {
if (isAndroid) {
// only support for android
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
// Android file transfer uses app-specific storage. User-selected
// files enter and leave this workspace through the system picker.
_homeDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationSupportDirectory()).path;
} else if (isIOS) {
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
// which provided the `downloads` path in the sandbox.
@@ -306,6 +307,12 @@ class PlatformFFI {
return await _toAndroidChannel.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
if (!isAndroid) return null;
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
}
void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
}

View File

@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
_audioOk = audioOption != 'N';
}
// file
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
_fileOk = false;
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
} else {
final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
}
// Android file transfer is confined to app-specific storage. Files enter
// and leave the workspace through Android's system document picker.
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
// clipboard
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
!await AndroidPermissionManager.check(kManageExternalStorage)) {
final res =
await AndroidPermissionManager.request(kManageExternalStorage);
if (!res) {
showToast(translate('Failed'));
return;
}
}
_fileOk = !_fileOk;
bind.mainSetOption(
key: kOptionEnableFileTransfer,
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
await checkFloatingWindowPermission();
}
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
await AndroidPermissionManager.request(kManageExternalStorage);
}
final res = await parent.target?.dialogManager
.show<bool>((setState, close, context) {
submit() => close(true);

View File

@@ -20,43 +20,68 @@ Future<void> writeTerminalClipboard(String text) async {
}
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
if (defaultTargetPlatform != TargetPlatform.linux) return null;
final platform = defaultTargetPlatform;
if (platform == TargetPlatform.linux) {
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
entry.key: entry.value,
_controlShiftVPasteShortcut:
const PasteTextIntent(SelectionChangedCause.keyboard),
};
}
if (platform != TargetPlatform.windows &&
platform != TargetPlatform.android) {
return null;
}
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlVShortcut(entry.key)) entry.key: entry.value,
_controlShiftVPasteShortcut:
const PasteTextIntent(SelectionChangedCause.keyboard),
if (!_isControlShortcut(
entry.key,
LogicalKeyboardKey.keyC,
shift: true,
))
entry.key: entry.value,
};
}
bool _isControlVShortcut(ShortcutActivator shortcut) =>
bool _isControlShortcut(
ShortcutActivator shortcut,
LogicalKeyboardKey key, {
bool shift = false,
}) =>
shortcut is SingleActivator &&
shortcut.trigger == LogicalKeyboardKey.keyV &&
shortcut.trigger == key &&
shortcut.control &&
!shortcut.shift &&
shortcut.shift == shift &&
!shortcut.alt &&
!shortcut.meta;
FocusOnKeyEventCallback terminalCopyHandler(
Terminal terminal,
TerminalController controller,
) =>
(_, event) {
if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored;
final selection = controller.selection;
if (selection == null || selection.isCollapsed) {
return KeyEventResult.ignored;
TerminalController controller, {
FocusOnKeyEventCallback? fallback,
}) =>
(focusNode, event) {
if (_isSelectionCopyShortcut(event)) {
final selection = controller.selection;
if (selection != null && !selection.isCollapsed) {
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text));
}
return KeyEventResult.handled;
}
}
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text));
}
return KeyEventResult.handled;
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
};
bool _isWindowsCopyShortcut(KeyEvent event) {
bool _isSelectionCopyShortcut(KeyEvent event) {
final keyboard = HardwareKeyboard.instance;
return defaultTargetPlatform == TargetPlatform.windows &&
final platform = defaultTargetPlatform;
final usesControlCopy =
platform == TargetPlatform.windows || platform == TargetPlatform.android;
return usesControlCopy &&
(event is KeyDownEvent || event is KeyRepeatEvent) &&
event.logicalKey == LogicalKeyboardKey.keyC &&
keyboard.isControlPressed &&

View File

@@ -251,6 +251,11 @@ class PlatformFFI {
return true;
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return null;
}
// just for compilation
void syncAndroidServiceAppDirConfigPath() {}

View File

@@ -409,14 +409,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "12.0.1"
external_path:
dependency: "direct main"
description:
name: external_path
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
ffi:
dependency: "direct main"
description:

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.4.9+67
version: 1.5.0+68
environment:
sdk: '^3.1.0'
@@ -29,7 +29,6 @@ dependencies:
ffi: ^2.1.0
path_provider: ^2.1.1
external_path: ^1.0.3
provider: ^6.0.5
tuple: ^2.0.0
wakelock_plus: ^1.1.3

View File

@@ -342,11 +342,43 @@ void main() {
});
group('shouldHandleTerminalPasteShortcut', () {
test('handles only Ctrl+Shift+V on Linux with a virtual lock', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.linux,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: true,
modifierLockActive: true,
),
isTrue,
);
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.linux,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
test(
'keeps default xterm paste behavior when virtual modifiers are inactive',
() {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
@@ -364,6 +396,7 @@ void main() {
() {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
@@ -377,6 +410,7 @@ void main() {
);
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.macOS,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
@@ -393,6 +427,7 @@ void main() {
test('handles paste shortcut repeats while a virtual lock is active', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: true,
@@ -409,6 +444,7 @@ void main() {
test('ignores key-up and unmodified V events', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: false,
@@ -422,6 +458,7 @@ void main() {
);
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
@@ -444,6 +481,7 @@ void main() {
]) {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
@@ -461,6 +499,7 @@ void main() {
test('ignores non-V key events', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyC,
isKeyDown: true,
isKeyRepeat: false,

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk-portable-packer"
version = "1.4.9"
version = "1.5.0"
edition = "2021"
description = "RustDesk Remote Desktop"
@@ -12,7 +12,7 @@ build = "build.rs"
brotli = "3.4"
dirs = "5.0"
md5 = "0.7"
winapi = { version = "0.3", features = ["winbase"] }
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [

View File

@@ -15,15 +15,29 @@ encoding = 'utf-8'
# output: {path: (compressed_data, file_md5)}
def generate_md5_table(folder: str, level) -> dict:
def normalize(path: str) -> str:
path = path.replace('\\', '/')
while path.startswith('./'):
path = path[2:]
return path.lower()
def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
res: dict = dict()
curdir = os.curdir
skip = normalize(exclude) if exclude else None
excluded = False
# os.curdir is the literal ".", so restoring it left us inside `folder`.
curdir = os.getcwd()
os.chdir(folder)
for root, _, files in os.walk('.'):
# remove ./
for f in files:
md5_generator = md5()
full_path = os.path.join(root, f)
if skip and normalize(full_path) == skip:
print(f"Excluding {full_path}...")
excluded = True
continue
print(f"Processing {full_path}...")
f = open(full_path, "rb")
content = f.read()
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
res[full_path] = (content_compressed, md5_code)
os.chdir(curdir)
if skip and not excluded:
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
return res
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
output_path = os.path.join(output_folder, "data.bin")
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
def write_blob(md5_table: dict, output_path: str, exe: str):
with open(output_path, "wb") as f:
f.write("rustdesk".encode(encoding=encoding))
for path in md5_table.keys():
@@ -92,6 +111,14 @@ if __name__ == '__main__':
help="the target used by cargo")
parser.add_option("-l", "--level", dest="level", type="int",
help="compression level, default is 11, highest", default=11)
parser.add_option("--package", dest="package",
help="write the per-customer blob to this path instead of "
"data.bin, and skip the cargo build. Injected into the "
"template's RDPKG resource so customizing needs no rebuild")
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
default=False,
help="omit the executable from the blob, for a template whose "
"executable ships in the package instead")
(options, args) = parser.parse_args()
folder = options.folder or './rustdesk'
output_folder = os.path.abspath(options.output_folder or './')
@@ -100,14 +127,29 @@ if __name__ == '__main__':
options.executable = 'rustdesk.exe'
if not options.executable.startswith(folder):
options.executable = folder + '/' + options.executable
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
# will result the print "Executable path: ..exe".
# So we need to check if the executable is in the folder, and if so, concat again.
if os.path.exists(os.path.join(folder, options.executable)):
options.executable = os.path.join(folder, options.executable)
folder_path = os.path.abspath(folder)
exe: str = os.path.abspath(options.executable)
if not exe.startswith(os.path.abspath(folder)):
try:
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
except ValueError:
in_source_folder = False
if not in_source_folder:
print("The executable must locate in source folder")
exit(-1)
exe = '.' + exe[len(os.path.abspath(folder)):]
exe = '.' + exe[len(folder_path):]
print("Executable path: " + exe)
print("Compression level: " + str(options.level))
md5_table = generate_md5_table(folder, options.level)
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)
md5_table = generate_md5_table(
folder, options.level, exe if options.exclude_exe else None)
if options.package:
write_blob(md5_table, os.path.abspath(options.package), exe)
else:
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)

View File

@@ -1,15 +1,22 @@
use std::{
collections::HashSet,
fs::{self},
io::{Cursor, Read},
path::Path,
};
// The generic payload, shared by every customer and compiled in once per release.
#[cfg(windows)]
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
#[cfg(not(windows))]
const BIN_DATA: &[u8] = &[];
// The per-customer payload, injected into the RCDATA resource after the template
// has been built, so that customizing a client needs no recompilation.
#[cfg(windows)]
const PACKAGE_RESOURCE_NAME: &str = "RDPKG";
// 4bytes
const LENGTH: usize = 4;
const IDENTIFIER: &[u8] = b"rustdesk";
const IDENTIFIER_LENGTH: usize = 8;
const MD5_LENGTH: usize = 32;
const BUF_SIZE: usize = 4096;
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
pub(crate) struct BinaryReader {
pub files: Vec<BinaryData>,
pub exe: String,
// Paths supplied by the per-customer package. Recorded so that a file dropped
// from a later package -- a logo the customer removed, say -- can be deleted
// from an existing extraction, which the timestamp wipe no longer covers now
// that the packer is built once per release rather than once per customer.
pub package_paths: Vec<String>,
}
impl Default for BinaryReader {
fn default() -> Self {
let (files, exe) = BinaryReader::read();
Self { files, exe }
impl BinaryReader {
pub fn new() -> Result<Self, String> {
let package = read_package()?;
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
let (files, exe) = merge(read_embedded()?, package);
Ok(Self {
files,
exe,
package_paths,
})
}
}
// Folds the per-customer package into the generic payload.
fn merge(
embedded: (Vec<BinaryData>, String),
package: (Vec<BinaryData>, String),
) -> (Vec<BinaryData>, String) {
let (mut files, generic_exe) = embedded;
let (package_files, package_exe) = package;
let exe = if package_exe.is_empty() {
generic_exe.clone()
} else {
package_exe
};
// The generic payload ships the executable under its stock name, the package
// decides the final one. Rename on extraction so the process is always
// `<appname>.exe`, which the app itself relies on to find its own sessions.
if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) {
let generic_key = normalize_path(&generic_exe);
for file in files.iter_mut() {
if normalize_path(&file.path) == generic_key {
file.path = exe.clone();
}
}
}
// Per-customer entries replace the generic ones they shadow.
if !package_files.is_empty() {
let overridden: HashSet<String> = package_files
.iter()
.map(|file| normalize_path(&file.path))
.collect();
files.retain(|file| !overridden.contains(&normalize_path(&file.path)));
files.extend(package_files);
}
(files, exe)
}
pub(crate) fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.to_lowercase()
}
fn read_u32(blob: &[u8], at: usize) -> Option<u32> {
let bytes = blob.get(at..at + LENGTH)?;
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
// Returns the files and the executable to launch, or None if the blob is absent or malformed.
fn parse(blob: &'static [u8]) -> Option<(Vec<BinaryData>, String)> {
let mut base = 0usize;
let mut parsed = Vec::new();
if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER {
return None;
}
base += IDENTIFIER_LENGTH;
loop {
if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER {
base += IDENTIFIER_LENGTH;
break;
}
let path_length = read_u32(blob, base)? as usize;
base += LENGTH;
let path = std::str::from_utf8(blob.get(base..base + path_length)?)
.ok()?
.to_owned();
base += path_length;
let file_length = read_u32(blob, base)? as usize;
base += LENGTH;
let raw = blob.get(base..base + file_length)?;
base += file_length;
let md5_code = blob.get(base..base + MD5_LENGTH)?;
base += MD5_LENGTH;
parsed.push(BinaryData {
md5_code,
raw,
path,
});
}
let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned();
Some((parsed, executable))
}
#[cfg(windows)]
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
}
#[cfg(not(windows))]
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
Ok(Default::default())
}
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, String), String> {
let Some(blob) = blob else {
return Ok(Default::default());
};
let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?;
if package.1.trim().is_empty() {
return Err("RDPKG resource has no executable".to_owned());
}
Ok(package)
}
#[cfg(windows)]
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
}
#[cfg(not(windows))]
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
Ok(Default::default())
}
// Reads an RCDATA resource out of the running image. Resources live in the mapped
// image for the lifetime of the process, so the slice is genuinely 'static and no
// copy is needed.
#[cfg(windows)]
fn read_resource(name: &str) -> Option<&'static [u8]> {
use std::ptr::null_mut;
use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource};
// MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA.
const RT_RCDATA: *const u16 = 10 as _;
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA);
if info.is_null() {
return None;
}
let size = SizeofResource(null_mut(), info) as usize;
if size == 0 {
return None;
}
let handle = LoadResource(null_mut(), info);
if handle.is_null() {
return None;
}
let data = LockResource(handle) as *const u8;
if data.is_null() {
return None;
}
Some(std::slice::from_raw_parts(data, size))
}
}
@@ -68,59 +235,6 @@ impl BinaryData {
}
impl BinaryReader {
fn read() -> (Vec<BinaryData>, String) {
let mut base: usize = 0;
let mut parsed = vec![];
assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!");
let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
if iden != "rustdesk" {
panic!("bin file is not valid!");
}
base += IDENTIFIER_LENGTH;
loop {
iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
if iden == "rustdesk" {
base += IDENTIFIER_LENGTH;
break;
}
// start reading
let mut offset = 0;
let path_length = u32::from_be_bytes([
BIN_DATA[base + offset],
BIN_DATA[base + offset + 1],
BIN_DATA[base + offset + 2],
BIN_DATA[base + offset + 3],
]) as usize;
offset += LENGTH;
let path =
String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length])
.to_string();
offset += path_length;
// file sz
let file_length = u32::from_be_bytes([
BIN_DATA[base + offset],
BIN_DATA[base + offset + 1],
BIN_DATA[base + offset + 2],
BIN_DATA[base + offset + 3],
]) as usize;
offset += LENGTH;
let raw = &BIN_DATA[base + offset..base + offset + file_length];
offset += file_length;
// md5
let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH];
offset += MD5_LENGTH;
parsed.push(BinaryData {
md5_code: md5,
raw: raw,
path: path,
});
base += offset;
}
// executable
let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string();
(parsed, executable)
}
#[cfg(linux)]
pub fn configure_permission(&self, prefix: &Path) {
use std::os::unix::prelude::PermissionsExt;
@@ -137,3 +251,155 @@ impl BinaryReader {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Builds a blob in the same layout generate.py writes, so these tests pin the
// cross-language format contract as well as the merge rules.
fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] {
let mut out = Vec::new();
out.extend_from_slice(IDENTIFIER);
for (path, data) in files {
out.extend_from_slice(&(path.len() as u32).to_be_bytes());
out.extend_from_slice(path.as_bytes());
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
out.extend_from_slice(&[b'a'; MD5_LENGTH]);
}
out.extend_from_slice(IDENTIFIER);
out.extend_from_slice(exe.as_bytes());
Box::leak(out.into_boxed_slice())
}
fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> {
files
.iter()
.find(|file| normalize_path(&file.path) == normalize_path(path))
}
#[test]
fn parses_the_generate_py_layout() {
let (files, exe) = parse(blob(
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
"./rustdesk.exe",
))
.unwrap();
assert_eq!(exe, "./rustdesk.exe");
assert_eq!(files.len(), 2);
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
}
#[test]
fn rejects_malformed_blobs() {
assert!(parse(b"".as_slice()).is_none());
assert!(parse(b"notrustd".as_slice()).is_none());
// Truncated mid-record rather than panicking on a slice out of range.
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
}
#[test]
fn distinguishes_an_absent_package_from_a_malformed_one() {
assert!(parse_package_blob(None).unwrap().0.is_empty());
assert!(parse_package_blob(Some(b"damaged")).is_err());
assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err());
}
#[test]
fn without_a_package_the_stock_payload_is_untouched() {
let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap();
let (files, exe) = merge(embedded, Default::default());
assert_eq!(exe, "./rustdesk.exe");
assert!(entry(&files, "./rustdesk.exe").is_some());
}
#[test]
fn renames_the_stock_executable_to_the_package_name() {
// x86: the big executable stays in the generic payload and only gets renamed.
let embedded = parse(blob(
&[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")],
"./rustdesk.exe",
))
.unwrap();
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert!(entry(&files, "./acme.exe").is_some());
assert!(entry(&files, "./rustdesk.exe").is_none());
// Untouched neighbours survive.
assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll");
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
}
#[test]
fn package_entries_win_over_the_generic_payload() {
// x64: the customized executable and icons ship in the package instead.
let embedded = parse(blob(
&[
("./data/flutter_assets/assets/icon.ico", b"stock-icon"),
("./librustdesk.dll", b"core"),
],
"./rustdesk.exe",
))
.unwrap();
let package = parse(blob(
&[
("./acme.exe", b"branded"),
("./data/flutter_assets/assets/icon.ico", b"acme-icon"),
],
"./acme.exe",
))
.unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert_eq!(
entry(&files, "./data/flutter_assets/assets/icon.ico")
.unwrap()
.raw,
b"acme-icon"
);
assert_eq!(
files
.iter()
.filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico")
.count(),
1
);
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
}
#[test]
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
let package = parse(blob(
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
"./acme.exe",
))
.unwrap();
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
paths.sort();
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
// Merging must not disturb them: the generic payload contributes none.
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
let (files, _) = merge(embedded, package);
assert!(entry(&files, "./data/logo.png").is_some());
}
#[test]
fn matches_paths_across_separator_styles() {
// generate.py emits backslashes when it runs on Windows.
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert!(entry(&files, "./acme.exe").is_some());
assert!(entry(&files, ".\\rustdesk.exe").is_none());
}
}

View File

@@ -5,7 +5,7 @@ use std::{
process::{Command, Stdio},
};
use bin_reader::BinaryReader;
use bin_reader::{normalize_path, BinaryReader};
pub mod bin_reader;
#[cfg(windows)]
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
const APP_METADATA: &[u8] = &[];
const APP_METADATA_CONFIG: &str = "meta.toml";
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
const META_LINE_PREFIX_FILE: &str = "file = ";
const APP_PREFIX: &str = "rustdesk";
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
#[cfg(windows)]
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
// The extraction directory follows whatever executable the payload asks for, so a
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
fn app_dir_name(exe: &str) -> String {
Path::new(&exe.replace('\\', "/"))
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.trim().to_lowercase())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| APP_PREFIX.to_owned())
}
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
return true;
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
false
}
fn write_meta(dir: &Path, ts: u64) {
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
let meta_file = dir.join(APP_METADATA_CONFIG);
if ts != 0 {
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
// Ignore is ok here
let _ = std::fs::write(meta_file, content);
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
for path in package_paths {
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
}
// Ignore is ok here
let _ = std::fs::write(meta_file, content);
}
fn previous_package_files(dir: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else {
return Vec::new();
};
content
.lines()
.filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE))
.map(|path| path.trim().to_owned())
.collect()
}
// meta.toml is plain text in a user-writable directory, and it now drives deletion,
// so the path is rebuilt from plain components rather than joined as written. A
// prefix, root or parent component would otherwise escape the extraction directory:
// Path::join replaces the base entirely when given an absolute path.
fn resolve_within(dir: &Path, relative: &str) -> Option<PathBuf> {
use std::path::Component;
let mut path = dir.to_path_buf();
let mut any = false;
for component in Path::new(&relative.replace('\\', "/")).components() {
match component {
Component::Normal(part) => {
// A drive-relative name like "C:x" parses as Normal, and only a
// Windows host would classify "C:/..." as a Prefix, so the colon is
// rejected outright rather than relying on the host's parser.
if part.to_string_lossy().contains(':') {
return None;
}
path.push(part);
any = true;
}
Component::CurDir => {}
_ => return None,
}
}
if any {
Some(path)
} else {
None
}
}
// A customer who drops a branding asset gets a package without it, and the file
// would otherwise linger in an existing extraction and keep being used. The wipe
// cannot cover this: it is keyed on the packer's build timestamp, which is now the
// same for every customer of a release.
fn remove_dropped_package_files_with<F>(
dir: &Path,
current: &[String],
mut remove_file: F,
) -> Vec<String>
where
F: FnMut(&Path) -> std::io::Result<()>,
{
let keep: std::collections::HashSet<String> =
current.iter().map(|p| normalize_path(p)).collect();
let mut failed = Vec::new();
for previous in previous_package_files(dir) {
if keep.contains(&normalize_path(&previous)) {
continue;
}
let Some(path) = resolve_within(dir, &previous) else {
continue;
};
if path.is_file() {
println!("removing dropped {}", previous);
if let Err(error) = remove_file(&path) {
eprintln!("failed to remove dropped {}: {}", previous, error);
failed.push(previous);
}
}
}
failed
}
fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec<String> {
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
}
fn setup(
@@ -71,7 +164,7 @@ fn setup(
} else {
// home dir
if let Some(dir) = dirs::data_local_dir() {
dir.join(APP_PREFIX)
dir.join(app_dir_name(&reader.exe))
} else {
eprintln!("not found data local dir");
return None;
@@ -87,10 +180,12 @@ fn setup(
}
std::fs::remove_dir_all(&dir).ok();
}
let mut metadata_paths = reader.package_paths.clone();
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
for file in reader.files.iter() {
file.write_to_file(&dir);
}
write_meta(&dir, ts);
write_meta(&dir, ts, &metadata_paths);
#[cfg(windows)]
win::copy_runtime_broker(&dir);
#[cfg(linux)]
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
}
}
fn main() {
fn main() -> Result<(), String> {
let mut args = Vec::new();
let mut arg_exe = Default::default();
let mut i = 0;
@@ -193,7 +288,7 @@ fn main() {
let quick_support = false;
let mut ui = false;
let reader = BinaryReader::default();
let reader = BinaryReader::new()?;
if let Some(exe) = setup(
reader,
None,
@@ -208,6 +303,7 @@ fn main() {
}
execute(exe, args, ui);
}
Ok(())
}
#[cfg(windows)]
@@ -246,3 +342,27 @@ mod win {
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
}
}
#[cfg(test)]
mod meta_tests {
use super::*;
#[test]
fn resolve_within_rejects_paths_that_escape() {
let base = Path::new("/base");
assert_eq!(
resolve_within(base, "./data/logo.png"),
Some(base.join("data").join("logo.png"))
);
assert_eq!(
resolve_within(base, ".\\data\\logo.png"),
Some(base.join("data").join("logo.png"))
);
// meta.toml is user-writable, so these must not reach remove_file.
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
assert_eq!(resolve_within(base, "/etc/passwd"), None);
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
assert_eq!(resolve_within(base, "."), None);
assert_eq!(resolve_within(base, ""), None);
}
}

View File

@@ -48,7 +48,6 @@ pub struct Capturer {
duplication: ComPtr<IDXGIOutputDuplication>,
fastlane: bool,
surface: ComPtr<IDXGISurface>,
readable: ComPtr<ID3D11Texture2D>,
texture: ComPtr<ID3D11Texture2D>,
width: usize,
height: usize,
@@ -164,7 +163,6 @@ impl Capturer {
duplication: ComPtr(duplication),
fastlane: desc.DesktopImageInSystemMemory == TRUE,
surface: ComPtr(ptr::null_mut()),
readable: ComPtr(ptr::null_mut()),
texture: ComPtr(ptr::null_mut()),
width: display.width() as usize,
height: display.height() as usize,
@@ -348,19 +346,19 @@ impl Capturer {
if self.fastlane {
wrap_hresult((*self.duplication.0).MapDesktopSurface(&mut rect))?;
} else {
self.ohgodwhat(frame.0)?;
self.surface = ComPtr(self.ohgodwhat(frame.0)?);
wrap_hresult((*self.surface.0).Map(&mut rect, DXGI_MAP_READ))?;
}
Ok((rect.pBits, rect.Pitch))
}
// copy from GPU memory to system memory
unsafe fn ohgodwhat(&mut self, frame: *mut IDXGIResource) -> io::Result<()> {
unsafe fn ohgodwhat(&mut self, frame: *mut IDXGIResource) -> io::Result<*mut IDXGISurface> {
let mut texture: *mut ID3D11Texture2D = ptr::null_mut();
wrap_hresult((*frame).QueryInterface(
(*frame).QueryInterface(
&IID_ID3D11Texture2D,
&mut texture as *mut *mut _ as *mut *mut _,
))?;
);
let texture = ComPtr(texture);
#[allow(invalid_value)]
@@ -372,37 +370,24 @@ impl Capturer {
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
texture_desc.MiscFlags = 0;
// Avoid per-frame staging texture allocation and the kernel allocation churn it causes.
let mut current: D3D11_TEXTURE2D_DESC = mem::zeroed();
if !self.surface.is_null() {
(*self.readable.0).GetDesc(&mut current);
}
if current.Width != texture_desc.Width
|| current.Height != texture_desc.Height
|| current.Format != texture_desc.Format
{
let mut readable = ptr::null_mut();
wrap_hresult((*self.device.0).CreateTexture2D(
&mut texture_desc,
ptr::null(),
&mut readable,
))?;
(*readable).SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM);
let readable = ComPtr(readable);
let mut readable = ptr::null_mut();
wrap_hresult((*self.device.0).CreateTexture2D(
&mut texture_desc,
ptr::null(),
&mut readable,
))?;
(*readable).SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM);
let readable = ComPtr(readable);
let mut surface = ptr::null_mut();
wrap_hresult((*readable.0).QueryInterface(
&IID_IDXGISurface,
&mut surface as *mut *mut _ as *mut *mut _,
))?;
let mut surface = ptr::null_mut();
(*readable.0).QueryInterface(
&IID_IDXGISurface,
&mut surface as *mut *mut _ as *mut *mut _,
);
self.readable = readable;
self.surface = ComPtr(surface);
}
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
(*self.context.0).CopyResource(self.readable.0 as *mut _, texture.0 as *mut _);
Ok(())
Ok(surface)
}
pub fn frame<'a>(&'a mut self, timeout: UINT) -> io::Result<Frame<'a>> {
@@ -500,10 +485,10 @@ impl Capturer {
}
let mut texture: *mut ID3D11Texture2D = ptr::null_mut();
wrap_hresult((*frame.0).QueryInterface(
(*frame.0).QueryInterface(
&IID_ID3D11Texture2D,
&mut texture as *mut *mut _ as *mut *mut _,
))?;
);
let texture = ComPtr(texture);
self.texture = texture;

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk
pkgver=1.4.9
pkgver=1.5.0
pkgrel=0
epoch=
pkgdesc=""

417
res/admin-roles.py Executable file
View File

@@ -0,0 +1,417 @@
#!/usr/bin/env python3
import argparse
import json
import requests
ROLE_TYPES = {
"global": 1,
"individual": 2,
"group": 3,
}
PERMISSION_IDS = {
"users.view": 0x0101,
"users.create": 0x0103,
"users.invite": 0x0104,
"users.delete": 0x0105,
"users.enable_disable": 0x0106,
"users.edit_email": 0x0107,
"users.edit_password": 0x0108,
"users.edit_note": 0x0109,
"users.manage_2fa": 0x010A,
"users.force_logout": 0x010B,
"users.change_group": 0x010C,
"users.change_strategy": 0x010D,
"users.change_control_role": 0x010E,
"users.edit_display_name": 0x010F,
"devices.view": 0x0201,
"devices.enable_disable": 0x0203,
"devices.delete": 0x0204,
"devices.edit_info": 0x0205,
"devices.assign_to_user": 0x0206,
"devices.change_group": 0x0207,
"devices.change_strategy": 0x0208,
"user_groups.view": 0x0301,
"user_groups.edit": 0x0302,
"device_groups.view": 0x0401,
"device_groups.edit": 0x0402,
"device_groups.change_strategy": 0x0403,
"audits.view": 0x0501,
"audits.edit": 0x0502,
"strategies.view": 0x0601,
"strategies.edit": 0x0602,
"custom_clients.view": 0x0701,
"custom_clients.edit": 0x0702,
"control_roles.view": 0x0801,
"control_roles.edit": 0x0802,
}
PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()}
def check_response(response):
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
if response.text and response.text.strip():
try:
data = response.json()
except ValueError:
return response.text
if isinstance(data, dict) and "error" in data:
print(f"Error: {data['error']}")
exit(1)
return data
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def split_csv(value):
if value is None:
return None
return [item.strip() for item in value.split(",") if item.strip()]
def parse_permissions(value):
permissions = []
for item in split_csv(value) or []:
permission = PERMISSION_IDS.get(item.lower())
if permission is None:
try:
permission = int(item, 0)
except ValueError:
print(f"Error: Invalid permission name or ID '{item}'")
exit(1)
if permission < 0 or permission > 65535:
print(f"Error: Permission ID '{item}' is outside the 0-65535 range")
exit(1)
permissions.append(permission)
return permissions
def format_role_permissions(role):
permissions = role.get("permissions")
if isinstance(permissions, list):
role["permissions"] = [
PERMISSION_NAMES.get(permission, permission) for permission in permissions
]
return role
def list_roles(url, token, name=None, role_type=None, page_size=50):
params = {"pageSize": page_size}
if name is not None:
params["name"] = name
if role_type is not None:
params["type"] = ROLE_TYPES[role_type]
roles = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/admin-roles", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing admin roles")
exit(1)
rows = data.get("data", [])
roles.extend(format_role_permissions(role) for role in rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return roles
def get_role(url, token, name=None, guid=None):
if guid:
response = requests.get(
f"{url}/api/admin-roles/{guid}", headers=headers_with(token)
)
role = check_response(response)
if isinstance(role, dict):
return format_role_permissions(role)
return role
roles = list_roles(url, token, name=name)
for role in roles:
if role.get("name") == name:
return role
return None
def resolve_role(url, token, name=None, guid=None):
role = get_role(url, token, name=name, guid=guid)
if role:
return role
target = guid if guid else name
print(f"Error: Admin role '{target}' not found")
exit(1)
def get_user_guid(url, token, name):
response = requests.get(
f"{url}/api/users",
headers=headers_with(token),
params={"name": name, "pageSize": 50, "current": 1},
)
data = check_response(response)
users = data.get("data", []) if isinstance(data, dict) else []
for user in users:
if user.get("name") == name:
return user.get("guid")
return None
def resolve_users(url, token, users):
guids = []
for user in users:
if len(user) == 36 and user.count("-") == 4:
guids.append(user)
continue
guid = get_user_guid(url, token, user)
if not guid:
print(f"Error: User '{user}' not found")
exit(1)
guids.append(guid)
return guids
def create_role(
url,
token,
name,
role_type,
permissions,
note=None,
user_groups=None,
device_groups=None,
unassigned=None,
):
payload = {
"name": name,
"type": ROLE_TYPES[role_type],
"permissions": permissions,
}
if note is not None:
payload["note"] = note
if user_groups:
payload["user_groups"] = user_groups
if device_groups:
payload["device_groups"] = device_groups
if unassigned is not None:
payload["unassigned"] = unassigned
response = requests.post(
f"{url}/api/admin-roles", headers=headers_with(token), json=payload
)
check_response(response)
def update_role(
url,
token,
guid,
new_name=None,
note=None,
permissions=None,
user_groups=None,
device_groups=None,
unassigned=None,
):
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
if permissions is not None:
payload["permissions"] = permissions
if user_groups is not None:
payload["user_groups"] = user_groups
if device_groups is not None:
payload["device_groups"] = device_groups
if unassigned is not None:
payload["unassigned"] = unassigned
response = requests.put(
f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload
)
check_response(response)
def delete_roles(url, token, guids):
response = requests.delete(
f"{url}/api/admin-roles",
headers=headers_with(token),
json={"guids": guids},
)
check_response(response)
def change_users(url, token, guid, users, remove=False):
method = requests.delete if remove else requests.post
response = method(
f"{url}/api/admin-roles/{guid}/users",
headers=headers_with(token),
json={"users": users},
)
check_response(response)
def view_users(url, token, role_guid, page_size=50):
params = {"admin_role_guid": role_guid, "pageSize": page_size}
users = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/users", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing users")
exit(1)
rows = data.get("data", [])
users.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return users
def require_role_target(parser, args):
if not args.name and not args.guid:
parser.error("one of --name or --guid is required")
def main():
parser = argparse.ArgumentParser(description="Admin role manager")
parser.add_argument(
"command",
choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"],
)
parser.add_argument("--url", required=True, help="Server URL")
parser.add_argument("--token", required=True, help="API token")
parser.add_argument("--name", help="Admin role name")
parser.add_argument("--guid", help="Admin role GUID")
parser.add_argument("--new-name", help="New admin role name")
parser.add_argument("--note", help="Role note; use an empty value to clear it")
parser.add_argument("--type", choices=ROLE_TYPES, help="Role type")
parser.add_argument(
"--permissions",
help="Comma-separated permission names or numeric IDs; use an empty value to clear",
)
parser.add_argument(
"--user-groups",
help="Comma-separated user group names; use an empty value to clear",
)
parser.add_argument(
"--device-groups",
help="Comma-separated device group names; use an empty value to clear",
)
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
unassigned = parser.add_mutually_exclusive_group()
unassigned.add_argument(
"--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices"
)
unassigned.add_argument(
"--no-unassigned",
dest="unassigned",
action="store_false",
help="Exclude unassigned devices",
)
parser.set_defaults(unassigned=None)
args = parser.parse_args()
args.url = args.url.rstrip("/")
if args.command == "view":
if args.guid:
result = resolve_role(args.url, args.token, guid=args.guid)
else:
result = list_roles(args.url, args.token, args.name, args.type)
print(json.dumps(result, indent=2))
return
if args.command == "add":
if not args.name or not args.type or args.permissions is None:
parser.error("--name, --type, and --permissions are required for add")
if args.type != "group" and (
args.user_groups is not None
or args.device_groups is not None
or args.unassigned is not None
):
parser.error("group scope options can only be used with --type group")
create_role(
args.url,
args.token,
args.name,
args.type,
parse_permissions(args.permissions),
args.note,
split_csv(args.user_groups),
split_csv(args.device_groups),
args.unassigned,
)
print(f"Success: Created admin role '{args.name}'")
return
require_role_target(parser, args)
role = resolve_role(args.url, args.token, args.name, args.guid)
role_guid = role.get("guid")
role_name = role.get("name")
if args.command == "update":
updates = [
args.new_name,
args.note,
args.permissions,
args.user_groups,
args.device_groups,
args.unassigned,
]
if all(value is None for value in updates):
parser.error("at least one update option is required")
if role.get("type") != ROLE_TYPES["group"] and (
args.user_groups is not None
or args.device_groups is not None
or args.unassigned is not None
):
parser.error("group scope options can only be used with a group role")
update_role(
args.url,
args.token,
role_guid,
args.new_name,
args.note,
parse_permissions(args.permissions) if args.permissions is not None else None,
split_csv(args.user_groups),
split_csv(args.device_groups),
args.unassigned,
)
print(f"Success: Updated admin role '{role_name}'")
elif args.command == "delete":
delete_roles(args.url, args.token, [role_guid])
print(f"Success: Deleted admin role '{role_name}'")
elif args.command == "view-users":
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
elif args.command in ("add-users", "remove-users"):
users = split_csv(args.users)
if not users:
parser.error("--users is required for add-users and remove-users")
user_guids = resolve_users(args.url, args.token, users)
remove = args.command == "remove-users"
change_users(args.url, args.token, role_guid, user_guids, remove=remove)
action = "Removed users from" if remove else "Added users to"
print(f"Success: {action} admin role '{role_name}'")
if __name__ == "__main__":
main()

292
res/control-roles.py Executable file
View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
import argparse
import json
import requests
STATUSES = {
"disabled": 0,
"enabled": 1,
}
def check_response(response):
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
if response.text and response.text.strip():
try:
data = response.json()
except ValueError:
return response.text
if isinstance(data, dict) and "error" in data:
print(f"Error: {data['error']}")
exit(1)
return data
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def split_csv(value):
if value is None:
return None
return [item.strip() for item in value.split(",") if item.strip()]
def list_roles(url, token, name=None, status=None, page_size=50):
params = {"pageSize": page_size}
if name is not None:
params["name"] = name
if status is not None:
params["status"] = STATUSES[status]
roles = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/control-roles", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing control roles")
exit(1)
rows = data.get("data", [])
for role in rows:
role.pop("info", None)
roles.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return roles
def get_role(url, token, name=None, guid=None):
if guid:
response = requests.get(
f"{url}/api/control-roles/{guid}", headers=headers_with(token)
)
role = check_response(response)
if isinstance(role, dict):
role.pop("info", None)
return role
roles = list_roles(url, token, name=name)
for role in roles:
if role.get("name") == name:
return role
return None
def resolve_role(url, token, name=None, guid=None):
role = get_role(url, token, name=name, guid=guid)
if role:
return role
target = guid if guid else name
print(f"Error: Control role '{target}' not found")
exit(1)
def get_user_guid(url, token, name):
response = requests.get(
f"{url}/api/users",
headers=headers_with(token),
params={"name": name, "pageSize": 50, "current": 1},
)
data = check_response(response)
users = data.get("data", []) if isinstance(data, dict) else []
for user in users:
if user.get("name") == name:
return user.get("guid")
return None
def resolve_users(url, token, users):
guids = []
for user in users:
if len(user) == 36 and user.count("-") == 4:
guids.append(user)
continue
guid = get_user_guid(url, token, user)
if not guid:
print(f"Error: User '{user}' not found")
exit(1)
guids.append(guid)
return guids
def create_role(url, token, name, note=None):
payload = {"name": name}
if note is not None:
payload["note"] = note
response = requests.post(
f"{url}/api/control-roles", headers=headers_with(token), json=payload
)
check_response(response)
def update_role(url, token, guid, new_name=None, note=None):
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
response = requests.put(
f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload
)
check_response(response)
def delete_roles(url, token, guids):
response = requests.delete(
f"{url}/api/control-roles",
headers=headers_with(token),
json={"guids": guids},
)
check_response(response)
def set_status(url, token, guids, disable):
response = requests.put(
f"{url}/api/control-roles/enable",
headers=headers_with(token),
json={"guids": guids, "disable": disable},
)
check_response(response)
def change_users(url, token, guid, users, remove=False):
if remove:
endpoint = f"{url}/api/control-roles/users"
response = requests.delete(
endpoint,
headers=headers_with(token),
json={"user_guids": users},
)
else:
endpoint = f"{url}/api/control-roles/{guid}/users"
response = requests.post(
endpoint,
headers=headers_with(token),
json={"user_guids": users},
)
check_response(response)
def view_users(url, token, role_guid, page_size=50):
params = {"control_role_guid": role_guid, "pageSize": page_size}
users = []
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(
f"{url}/api/users", headers=headers_with(token), params=params
)
data = check_response(response)
if not isinstance(data, dict):
print("Error: Unexpected response while listing users")
exit(1)
rows = data.get("data", [])
users.extend(rows)
total = data.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return users
def require_role_target(parser, args):
if not args.name and not args.guid:
parser.error("one of --name or --guid is required")
def main():
parser = argparse.ArgumentParser(
description="Control role manager (configure control permissions in the web console)"
)
parser.add_argument(
"command",
choices=[
"view",
"add",
"update",
"delete",
"enable",
"disable",
"view-users",
"assign-users",
"remove-users",
],
)
parser.add_argument("--url", required=True, help="Server URL")
parser.add_argument("--token", required=True, help="API token")
parser.add_argument("--name", help="Control role name")
parser.add_argument("--guid", help="Control role GUID")
parser.add_argument("--new-name", help="New control role name")
parser.add_argument("--note", help="Role note; use an empty value to clear it")
parser.add_argument("--status", choices=STATUSES, help="Status filter for view")
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
args = parser.parse_args()
args.url = args.url.rstrip("/")
if args.command == "view":
if args.guid:
result = resolve_role(args.url, args.token, guid=args.guid)
else:
result = list_roles(args.url, args.token, args.name, args.status)
print(json.dumps(result, indent=2))
return
if args.command == "add":
if not args.name:
parser.error("--name is required for add")
create_role(args.url, args.token, args.name, args.note)
print(f"Success: Created control role '{args.name}'")
return
if args.command == "remove-users":
users = split_csv(args.users)
if not users:
parser.error("--users is required for remove-users")
user_guids = resolve_users(args.url, args.token, users)
change_users(args.url, args.token, None, user_guids, remove=True)
print("Success: Removed users from their control roles")
return
require_role_target(parser, args)
role = resolve_role(args.url, args.token, args.name, args.guid)
role_guid = role.get("guid")
role_name = role.get("name")
if args.command == "update":
if args.new_name is None and args.note is None:
parser.error("--new-name or --note is required for update")
update_role(args.url, args.token, role_guid, args.new_name, args.note)
print(f"Success: Updated control role '{role_name}'")
elif args.command == "delete":
delete_roles(args.url, args.token, [role_guid])
print(f"Success: Deleted control role '{role_name}'")
elif args.command in ("enable", "disable"):
disable = args.command == "disable"
set_status(args.url, args.token, [role_guid], disable)
print(f"Success: {args.command.title()}d control role '{role_name}'")
elif args.command == "view-users":
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
elif args.command == "assign-users":
users = split_csv(args.users)
if not users:
parser.error("--users is required for assign-users")
user_guids = resolve_users(args.url, args.token, users)
change_users(args.url, args.token, role_guid, user_guids)
print(f"Success: Assigned users to control role '{role_name}'")
if __name__ == "__main__":
main()

View File

@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
namespace RemotePrinter
{
VOID installUpdatePrinter(const std::wstring& installFolder);
VOID uninstallPrinter();
// `appName` names the printer and its port. It is passed in rather than compiled
// in so that a single dll serves every custom client; an empty value keeps the
// stock "RustDesk Printer" name.
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
VOID uninstallPrinter(const std::wstring& appName);
}

View File

@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
{
do
{
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
{
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
if (process != NULL)
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
DWORD er = ERROR_SUCCESS;
int nResult = 0;
LPWSTR installFolder = NULL;
LPWSTR pwz = NULL;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
std::wstring installFolderValue;
hr = WcaInitialize(hInstall, "InstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
pwz = pwzData;
hr = WcaReadStringFromCaData(&pwz, &installFolder);
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
// "<app name>|<install folder>". Split here rather than through
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
// Formatted property value cannot carry.
{
std::wstring data(pwzData);
size_t separator = data.find(L'|');
if (separator == std::wstring::npos)
{
// A package built before the name was passed in; keep the stock name.
appNameValue.clear();
installFolderValue = data;
}
else
{
appNameValue = data.substr(0, separator);
installFolderValue = data.substr(separator + 1);
}
}
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
RemotePrinter::installUpdatePrinter(installFolder);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
LExit:
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
HRESULT hr = S_OK;
DWORD er = ERROR_SUCCESS;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
hr = WcaInitialize(hInstall, "UninstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
// Must match the name install used, otherwise the printer is left behind. Absent
// on packages built before this was passed in, where it was the stock name.
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
if (pwzData)
{
appNameValue = pwzData;
}
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
RemotePrinter::uninstallPrinter();
RemotePrinter::uninstallPrinter(appNameValue);
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
LExit:
if (pwzData) {
ReleaseStr(pwzData);
}
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}

View File

@@ -18,12 +18,19 @@ namespace RemotePrinter
{
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
// The driver files and the driver name ship with the app under their stock names
// and stay fixed for every custom client. Only the printer and its port carry the
// app name, and that arrives at runtime so one dll serves every custom client.
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
static std::wstring printerNameOf(const std::wstring &appName)
{
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
}
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
template <typename T, typename R>
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
@@ -458,8 +465,12 @@ namespace RemotePrinter
// We should not check the driver version because the driver is deployed with the application.
// It's better to uninstall the existing driver and install the driver from the application.
// 3. Add the printer.
VOID installUpdatePrinter(const std::wstring &installFolder)
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
{
const std::wstring printerName = printerNameOf(appName);
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
if (!FileExists(infFile))
{
@@ -505,13 +516,15 @@ namespace RemotePrinter
}
}
VOID uninstallPrinter()
VOID uninstallPrinter(const std::wstring &appName)
{
deletePrinter(RD_PRINTER_NAME);
const std::wstring printerName = printerNameOf(appName);
deletePrinter(printerName.c_str());
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
uninstallDriver(RD_PRINTER_DRIVER_NAME);
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
checkDeleteLocalPort(RD_PRINTER_PORT);
checkDeleteLocalPort(printerName.c_str());
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
}
}

View File

@@ -30,7 +30,14 @@
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
<!-- The app name comes first and is separated by '|', which cannot occur in a
Windows path nor in a validated app name. wcautil's own delimiter is a
literal wide char 128 that a Formatted value cannot carry, and [~] is
MSI's NUL escape rather than that delimiter, so the action parses this
itself. Passing the name keeps the dll free of it, so one build serves
every custom client. -->
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
<InstallExecuteSequence>
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
@@ -86,6 +93,7 @@
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>

View File

@@ -13,6 +13,12 @@
<PropertyRef Id="AddRemovePropertiesFile" />
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
<!--$Media2Start$-->
<!-- preprocess.py in template mode adds a second cabinet here, holding only
the files that differ per customer, so a custom client can be produced by
rebuilding that small cabinet instead of the whole package. The shipped
msi is built without template mode and keeps a single cabinet. -->
<!--$Media2End$-->
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />

View File

@@ -10,7 +10,6 @@ import subprocess
import re
import platform
from pathlib import Path
from itertools import chain
import shutil
from xml.sax.saxutils import quoteattr
@@ -67,6 +66,14 @@ def make_parser():
parser.add_argument(
"-c", "--custom", action="store_true", help="Is custom client", default=False
)
parser.add_argument(
"--template",
action="store_true",
default=False,
help="Build a template to be patched per customer rather than a finished "
"package: puts the files a custom client replaces in their own cabinet, so "
"rebranding rebuilds a few hundred KB instead of the whole payload.",
)
parser.add_argument(
"--conn-type",
type=str,
@@ -92,6 +99,43 @@ def make_parser():
return parser
# Files a custom client replaces. Kept in their own cabinet by --template so that
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
# The app executable is handled separately: it has its own component in RustDesk.wxs.
#
# A template has to ship a placeholder for each of these so there is a File row to
# patch, but the branding assets are optional for a customer and a stock build has
# none of them at all. So each optional one installs only when its property is set,
# which the patcher does for the files a customer actually supplied. Otherwise a
# customer without a logo would install the placeholder, where today they get no
# logo at all -- the client treats a missing asset as "no logo".
PER_CUSTOMER_DISK_ID = 2
PER_CUSTOMER_FILES = {
# relative path -> property gating installation, or None if always installed
"custom.txt": None,
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
}
def normalize_relative(relative_path):
path = relative_path.replace("\\", "/")
while path.startswith("./"):
path = path[2:]
return path.lower()
def is_per_customer(relative_path):
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
def per_customer_condition(relative_path):
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
def read_lines_and_start_index(file_path, tag_start, tag_end):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
return lines, index_start
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
indent = g_indent_unit * 3
path = Path(dist_dir)
idx = 1
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
if subdir != ".":
dir_attr = f'Subdirectory="{subdir}"'
relative = file_path.relative_to(path).as_posix()
disk_attr = ""
condition_attr = ""
if template and is_per_customer(relative):
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
# Branding assets are optional, and the template only carries a
# placeholder, so install one only when the customer supplied it.
condition = per_customer_condition(relative)
if condition:
condition_attr = f' Condition="{condition} = 1"'
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
# because it will cause error
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
to_insert_lines = f"""
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
{indent}</Component>
"""
lines.insert(index_start + 1, to_insert_lines[1:])
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
return True
def gen_auto_component(app_name, dist_dir):
def gen_auto_component(app_name, dist_dir, template=False):
return gen_content_between_tags(
"Package/Components/RustDesk.wxs",
"<!--$AutoComonentStart$-->",
"<!--$AutoComponentEnd$-->",
lambda lines, index_start: insert_components_between_tags(
lines, index_start, app_name, dist_dir
lines, index_start, app_name, dist_dir, template
),
)
def gen_media2():
"""Second cabinet holding only what a custom client replaces."""
def func(lines, index_start):
indent = g_indent_unit * 2
lines.insert(
index_start + 1,
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
' EmbedCab="yes" CompressionLevel="high" />\n',
)
return lines
return gen_content_between_tags(
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
)
def put_app_exe_on_media2():
"""The app executable has its own component, so it is moved by name."""
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
with open(target, "r", encoding="utf-8") as f:
content = f.read()
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
new = (
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
)
if content.count(old) != 1:
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
return False
with open(target, "w", encoding="utf-8") as f:
f.write(content.replace(old, new))
return True
def gen_pre_vars(args, dist_dir):
def func(lines, index_start):
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def replace_app_name_in_custom_actions(app_name):
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
for i, line in enumerate(lines):
line = re.sub(r"\bRustDesk\b", app_name, line)
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
lines[i] = line
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def gen_upgrade_info():
def func(lines, index_start):
indent = g_indent_unit * 3
@@ -478,11 +556,16 @@ if __name__ == "__main__":
if not gen_conn_type(args):
sys.exit(-1)
if not gen_auto_component(app_name, dist_dir):
if args.template:
if not gen_media2():
sys.exit(-1)
if not put_app_exe_on_media2():
sys.exit(-1)
if not gen_auto_component(app_name, dist_dir, args.template):
sys.exit(-1)
if not gen_custom_dialog_bitmaps():
sys.exit(-1)
replace_app_name_in_langs(args.app_name)
replace_app_name_in_custom_actions(args.app_name)

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.9
Version: 1.5.0
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.9
Version: 1.5.0
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.9
Version: 1.5.0
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1462,6 +1462,18 @@ impl<T: InvokeUiSession> Remote<T> {
!lc.disable_clipboard.v && !lc.view_only.v
};
if clipboard_allowed {
#[cfg(all(
feature = "flutter",
not(any(target_os = "android", target_os = "ios"))
))]
if self.handler.is_text_clipboard_required()
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
{
let mut msg = Message::new();
msg.set_clipboard(cb.clone());
let session_id = self.handler.lc.read().unwrap().session_id;
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(vec![cb], ClipboardSide::Client);
#[cfg(target_os = "ios")]
@@ -1485,6 +1497,18 @@ impl<T: InvokeUiSession> Remote<T> {
!lc.disable_clipboard.v && !lc.view_only.v
};
if clipboard_allowed {
#[cfg(all(
feature = "flutter",
not(any(target_os = "android", target_os = "ios"))
))]
if self.handler.is_text_clipboard_required()
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
{
let mut msg = Message::new();
msg.set_multi_clipboards(_mcb.clone());
let session_id = self.handler.lc.read().unwrap().session_id;
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(_mcb.clipboards, ClipboardSide::Client);
#[cfg(target_os = "ios")]

View File

@@ -13,6 +13,17 @@ pub const CLIPBOARD_NAME: &'static str = "clipboard";
pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard";
pub const CLIPBOARD_INTERVAL: u64 = 333;
pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str =
"allow-sync-clipboard-between-sessions";
#[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))]
pub fn is_sync_clipboard_between_sessions_enabled() -> bool {
hbb_common::config::option2bool(
OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS,
&hbb_common::config::LocalConfig::get_option(OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS),
)
}
// This format is used to store the flag in the clipboard.
const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner";

View File

@@ -222,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool {
}
}
/// Android is scoped-storage only: the peer may never touch anything outside the app
/// workspace (`Config::get_home()`, i.e. the app-specific external files directory).
///
/// Every peer supplied path must be validated with this before it reaches the
/// filesystem, for reads, writes, renames, creations and deletions alike. The path is
/// resolved to its canonical form (of the deepest existing ancestor, so paths that are
/// about to be created are handled too) so symlinks cannot escape the workspace.
///
/// Only the `ReadDir` protocol action treats an empty path as the home directory.
/// Callers must opt in to that protocol-specific behavior with `allow_empty`.
#[cfg(target_os = "android")]
pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool {
use std::path::{Component, Path, PathBuf};
// Canonicalize the deepest existing ancestor and re-append the missing tail.
fn resolve(path: &Path) -> Option<PathBuf> {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut base = path.to_path_buf();
loop {
if let Ok(mut resolved) = base.canonicalize() {
while let Some(component) = tail.pop() {
resolved.push(component);
}
return Some(resolved);
}
tail.push(base.file_name()?.to_os_string());
if !base.pop() {
return None;
}
}
}
if path.is_empty() {
return allow_empty;
}
let path = Path::new(path);
// `..` is never needed by the protocol and would defeat the prefix check below.
if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) {
return false;
}
let home = Config::get_home();
let home = home.canonicalize().unwrap_or(home);
if home.as_os_str().is_empty() {
return false;
}
// `Path::starts_with` compares whole components, and is true for equal paths.
resolve(path).map_or(false, |target| target.starts_with(&home))
}
#[inline]
#[cfg(not(target_os = "android"))]
pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool {
true
}
#[inline]
pub fn is_main() -> bool {
*IS_MAIN

View File

@@ -1422,10 +1422,26 @@ pub fn update_file_clipboard_required() {
#[cfg(not(target_os = "ios"))]
pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
send_clipboard_msg_impl(msg, _is_file, None);
}
// `except_session_id` is the session the content came from, to avoid sending it back.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn send_clipboard_msg_to_other_sessions(msg: Message, except_session_id: u64) {
send_clipboard_msg_impl(msg, false, Some(except_session_id));
}
#[cfg(not(target_os = "ios"))]
fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Option<u64>) {
for s in sessions::get_sessions() {
if !s.is_default() {
continue;
}
if let Some(except_session_id) = except_session_id {
if s.lc.read().unwrap().session_id == except_session_id {
continue;
}
}
#[cfg(feature = "unix-file-copy-paste")]
if _is_file {
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)

View File

@@ -2912,6 +2912,7 @@ pub mod server_side {
env: JNIEnv,
_class: JClass,
app_dir: JString,
home_dir: JString,
custom_client_config: JString,
) {
log::debug!("startServer from jvm");
@@ -2919,6 +2920,9 @@ pub mod server_side {
if let Ok(app_dir) = env.get_string(&app_dir) {
*config::APP_DIR.write().unwrap() = app_dir.into();
}
if let Ok(home_dir) = env.get_string(&home_dir) {
*config::APP_HOME_DIR.write().unwrap() = home_dir.into();
}
if let Ok(custom_client_config) = env.get_string(&custom_client_config) {
if !custom_client_config.is_empty() {
let custom_client_config: String = custom_client_config.into();

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"),
("screenshot-action-tip", "إجراء لقطة الشاشة"),
("Save as", "حفظ باسم"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "نسخ إلى الحافظة"),
("Enable remote printer", "تمكين الطابعة عن بُعد"),
("Downloading {}", "جارٍ تنزيل {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "متابعة"),
("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."),
("Lock canvas", "قفل اللوحة"),
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."),
("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."),
("Save as", "Захаваць у файл"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Скапіяваць у буфер абмену"),
("Enable remote printer", "Выкарыстоўваць аддалены прынтар"),
("Downloading {}", "Ідзе спампоўванне {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Працягнуць"),
("Browser didn't open? Use the url below to sign in.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."),
("Lock canvas", "Заблакіраваць палатно"),
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."),
("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."),
("Save as", "Запазване като"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Копиране в клипборда"),
("Enable remote printer", "Позволяване на отдалечен принтер"),
("Downloading {}", "Изтегляне на {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Продължи"),
("Browser didn't open? Use the url below to sign in.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."),
("Lock canvas", "Заключване на платното"),
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."),
("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."),
("Save as", "Anomena i desa"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copia al porta-retalls"),
("Enable remote printer", "Habilita l'impressora remota"),
("Downloading {}", "Descarregant {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continua"),
("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."),
("Lock canvas", "Bloca el llenç"),
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
("screenshot-action-tip", "请选择如何继续截屏。"),
("Save as", "另存为"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "复制到剪贴板"),
("Enable remote printer", "启用远程打印机"),
("Downloading {}", "正在下载 {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "继续"),
("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"),
("Lock canvas", "锁定画布"),
("Sync clipboard between sessions", "在会话间同步剪贴板"),
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."),
("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."),
("Save as", "Uložit jako"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopírovat do schránky"),
("Enable remote printer", "Povolit vzdálenou tiskárnu"),
("Downloading {}", "Stahuje se {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Pokračovat"),
("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."),
("Lock canvas", "Zamknout zobrazení"),
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."),
("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."),
("Save as", "Gem som"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiér til udklipsholder"),
("Enable remote printer", "Aktivér fjernprinter"),
("Downloading {}", "Downloader {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Fortsæt"),
("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."),
("Lock canvas", "Lås lærred"),
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."),
("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."),
("Save as", "Speichern unter"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "In Zwischenablage kopieren"),
("Enable remote printer", "Entfernten Drucker aktivieren"),
("Downloading {}", "{} herunterladen"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Weiter"),
("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."),
("Lock canvas", "Sichtfeld sperren"),
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."),
("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."),
("Save as", "Αποθήκευση ως"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Αντιγραφή στο πρόχειρο"),
("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"),
("Downloading {}", "Γίνεται Λήψη {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Συνέχεια"),
("Browser didn't open? Use the url below to sign in.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."),
("Lock canvas", "Κλείδωμα καμβά"),
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
].iter().cloned().collect();
}

View File

@@ -275,5 +275,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("id_whitelist_caveat_tip", "The ID is reported by the connecting client. This whitelist reduces exposure and does not replace the password or 2FA."),
("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"),
("Your ip is blocked by the peer", "Your IP is blocked by the peer"),
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."),
("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."),
("Save as", "Konservi kiel"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopii al la poŝo"),
("Enable remote printer", "Ebligi foran presilon"),
("Downloading {}", "Elŝutas {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Daŭrigi"),
("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."),
("Lock canvas", "Ŝlosi kanvason"),
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."),
("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."),
("Save as", "Guardar como"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copiar al portapapeles"),
("Enable remote printer", "Habilitar impresora remota"),
("Downloading {}", "Descargando {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continuar"),
("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."),
("Lock canvas", "Bloquear lienzo"),
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."),
("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."),
("Save as", "Salvesta kui"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopeeri lõikelauale"),
("Enable remote printer", "Luba kaugprinter"),
("Downloading {}", "Allalaadimine: {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Jätka"),
("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."),
("Lock canvas", "Lukusta lõuend"),
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."),
("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."),
("Save as", "Gorde honela"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiatu arbelera"),
("Enable remote printer", "Gaitu urruneko inprimagailua"),
("Downloading {}", "{} deskargatzen"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Jarraitu"),
("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."),
("Lock canvas", "Blokeatu oihala"),
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."),
("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."),
("Save as", "ذخیره به عنوان"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "در کلیپ بورد کپی کنید"),
("Enable remote printer", "چاپگر از راه دور را فعال کنید"),
("Downloading {}", "بارگیری {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "ادامه"),
("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."),
("Lock canvas", "قفل کردن صفحه"),
("Sync clipboard between sessions", "همگام‌سازی کلیپ‌بورد بین نشست‌ها"),
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی می‌شوند به کلیپ‌بورد سایر نشست‌های متصل شما نیز ارسال می‌شوند."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"),
("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"),
("Save as", "Tallenna nimellä"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopioi leikepöydälle"),
("Enable remote printer", "Ota etätulostin käyttöön"),
("Downloading {}", "Ladataan {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Jatka"),
("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."),
("Lock canvas", "Lukitse näkymä"),
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture décran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."),
("screenshot-action-tip", "Veuillez choisir laction à effectuer avec la capture décran."),
("Save as", "Enregistrer sous"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copier dans le presse-papier"),
("Enable remote printer", "Activer limpression à distance"),
("Downloading {}", "Téléchargement de {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continuer"),
("Browser didn't open? Use the url below to sign in.", "Le navigateur ne sest pas ouvert ? Utilisez lURL ci-dessous pour vous connecter."),
("Lock canvas", "Verrouiller la vue"),
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."),
("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."),
("Save as", "შენახვა როგორც"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "ბუფერში კოპირება"),
("Enable remote printer", "დისტანციური პრინტერის ჩართვა"),
("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "გაგრძელება"),
("Browser didn't open? Use the url below to sign in.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."),
("Lock canvas", "ტილოს დაბლოკვა"),
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."),
("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"),
("Save as", "તરીકે સાચવો"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"),
("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"),
("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "ચાલુ રાખો"),
("Browser didn't open? Use the url below to sign in.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."),
("Lock canvas", "કેનવાસ લોક કરો"),
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"),
("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"),
("Save as", "שמור בשם"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "העתק ללוח"),
("Enable remote printer", "אפשר מדפסת מרוחקת"),
("Downloading {}", "מוריד את {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "המשך"),
("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."),
("Lock canvas", "נעל לוח ציור"),
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"),
("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"),
("Save as", "इस रूप में सहेजें"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"),
("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"),
("Downloading {}", "{} डाउनलोड हो रहा है"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "जारी रखें"),
("Browser didn't open? Use the url below to sign in.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"),
("Lock canvas", "कैनवास लॉक करें"),
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."),
("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."),
("Save as", "Spremi kao"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiraj u međuspremnik"),
("Enable remote printer", "Omogući udaljeni pisač"),
("Downloading {}", "Preuzimanje {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Nastavi"),
("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."),
("Lock canvas", "Zaključaj pozadinu"),
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"),
("screenshot-action-tip", "Képernyőkép-művelet"),
("Save as", "Mentés másként"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Másolás a vágólapra"),
("Enable remote printer", "Távoli nyomtatók engedélyezése"),
("Downloading {}", "{} letöltése"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Folytatás"),
("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."),
("Lock canvas", "Nézet zárolása"),
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."),
("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."),
("Save as", "Simpan sebagai"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Salin ke papan klip"),
("Enable remote printer", "Aktifkan printer jarak jauh"),
("Downloading {}", "Mendownload {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Lanjutkan"),
("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."),
("Lock canvas", "Kunci kanvas"),
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."),
("screenshot-action-tip", "Seleziona come continuare con la schermata."),
("Save as", "Salva come"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copia negli appunti"),
("Enable remote printer", "Abilita stampante remota"),
("Downloading {}", "Download {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continua"),
("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."),
("Lock canvas", "Blocca tela"),
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"),
("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"),
("Save as", "保存先"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "クリップボードにコピー"),
("Enable remote printer", "リモートプリンターを有効化する"),
("Downloading {}", "{} をダウンロード中"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "続行"),
("Browser didn't open? Use the url below to sign in.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"),
("Lock canvas", "キャンバスをロック"),
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."),
("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."),
("Save as", "다른 이름으로 저장"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "클립보드에 복사"),
("Enable remote printer", "원격 프린터 허용"),
("Downloading {}", "{} 다운로드 중"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "계속"),
("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."),
("Lock canvas", "캔버스 잠금"),
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."),
("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."),
("Save as", "Басқаша сақтау"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Көшіру-тақтаға көшіру"),
("Enable remote printer", "Қашықтағы принтерді іске қосу"),
("Downloading {}", "{} жүктелуде"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Жалғастыру"),
("Browser didn't open? Use the url below to sign in.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."),
("Lock canvas", "Кенепті құлыптау"),
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."),
("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."),
("Save as", "Įrašyti kaip"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopijuoti į iškarpinę"),
("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"),
("Downloading {}", "Atsisiunčiama {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Tęsti"),
("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."),
("Lock canvas", "Užrakinti drobę"),
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."),
("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."),
("Save as", "Saglabāt kā"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopēt starpliktuvē"),
("Enable remote printer", "Iespējot attālo printeri"),
("Downloading {}", "Notiek {} lejupielāde"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Turpināt"),
("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."),
("Lock canvas", "Bloķēt audeklu"),
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."),
("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"),
("Save as", "പേരിൽ സേവ് ചെയ്യുക"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"),
("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"),
("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "തുടരുക"),
("Browser didn't open? Use the url below to sign in.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."),
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."),
("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."),
("Save as", "Lagre som"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopier til utklipstavlen"),
("Enable remote printer", "Aktiver fjernskriver"),
("Downloading {}", "Laster ned {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Fortsett"),
("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."),
("Lock canvas", "Lås lerret"),
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
("Save as", "Opslaan als"),
("Export", "Exporteren"),
("Export Logs", "Logboeken exporteren"),
("Import Folder", "Map importeren"),
("Copy to clipboard", "Kopiëren naar het klembord"),
("Enable remote printer", "Printer op afstand inschakelen"),
("Downloading {}", "Downloaden {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Doorgaan"),
("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."),
("Lock canvas", "Canvas vergrendelen"),
("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"),
("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."),
("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."),
("Save as", "Zapisz jako"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiuj do schowka"),
("Enable remote printer", "Włącz zdalne drukowanie"),
("Downloading {}", "Pobieranie {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Kontynuuj"),
("Browser didn't open? Use the url below to sign in.", "Przeglądarka się nie otworzyła? Użyj poniższego adresu URL, aby się zalogować."),
("Lock canvas", "Zablokuj ekran"),
("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"),
("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."),
("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."),
("Save as", "Guardar como"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copiar para a área de transferência"),
("Enable remote printer", "Ativar impressora remota"),
("Downloading {}", "A transferir {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continuar"),
("Browser didn't open? Use the url below to sign in.", "O navegador não abriu? Utilize o URL abaixo para iniciar sessão."),
("Lock canvas", "Bloquear tela"),
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
("Save as", "Salvar como"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copiar para área de transferência"),
("Enable remote printer", "Habilitar impressora remota"),
("Downloading {}", "Baixando {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continuar"),
("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."),
("Lock canvas", "Bloquear tela"),
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."),
("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."),
("Save as", "Salvează ca"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copiază în clipboard"),
("Enable remote printer", "Activează imprimanta la distanță"),
("Downloading {}", "Se descarcă {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Continuă"),
("Browser didn't open? Use the url below to sign in.", "Browserul nu s-a deschis? Folosește URL-ul de mai jos pentru a te conecta."),
("Lock canvas", "Blochează ecranul"),
("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"),
("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."),
("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."),
("Save as", "Сохранить в файл"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Копировать в буфер обмена"),
("Enable remote printer", "Использовать удалённый принтер"),
("Downloading {}", "Скачивание"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Продолжить"),
("Browser didn't open? Use the url below to sign in.", "Браузер не открылся? Используйте ссылку ниже для входа."),
("Lock canvas", "Заблокировать холст"),
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."),
("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."),
("Save as", "Sarva comente"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Còpia in punta de billete"),
("Enable remote printer", "Abìlita imprentadora remota"),
("Downloading {}", "Iscarrighende {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Sighi"),
("Browser didn't open? Use the url below to sign in.", "Non s'est abertu su navigadore? Imprea s'URL inoghe in suta pro intrare."),
("Lock canvas", "Bloca sa tela"),
("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"),
("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."),
("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."),
("Save as", "Uložiť ako"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopírovať do schránky"),
("Enable remote printer", "Povoliť vzdialenú tlačiareň"),
("Downloading {}", "Sťahuje sa {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Pokračovať"),
("Browser didn't open? Use the url below to sign in.", "Neotvoril sa prehliadač? Na prihlásenie použite URL nižšie."),
("Lock canvas", "Uzamknúť zobrazenie"),
("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"),
("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."),
("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."),
("Save as", "Shrani kot"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiraj v odložišče"),
("Enable remote printer", "Omogoči oddaljeni tiskalnik"),
("Downloading {}", "Prenašanje {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Nadaljuj"),
("Browser didn't open? Use the url below to sign in.", "Brskalnik se ni odprl? Za prijavo uporabite spodnji URL."),
("Lock canvas", "Zakleni platno"),
("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"),
("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."),
("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."),
("Save as", "Ruaj si"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopjo te clipboard"),
("Enable remote printer", "Aktivizo printerin në distancë"),
("Downloading {}", "Duke shkarkuar {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Vazhdo"),
("Browser didn't open? Use the url below to sign in.", "Shfletuesi nuk u hap? Përdorni URL-në më poshtë për të hyrë."),
("Lock canvas", "Kyç canvas"),
("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"),
("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."),
].iter().cloned().collect();
}

View File

@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."),
("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."),
("Save as", "Sačuvaj kao"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiraj u clipboard"),
("Enable remote printer", "Omogući udaljeni štampač"),
("Downloading {}", "Preuzimanje {}"),
@@ -758,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Continue", "Nastavi"),
("Browser didn't open? Use the url below to sign in.", "Pregledač se nije otvorio? Za prijavu koristite URL ispod."),
("Lock canvas", "Zaključaj pozadinu"),
("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"),
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."),
].iter().cloned().collect();
}

Some files were not shown because too many files have changed in this diff Show More