Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
fca50c9
The explorer filter keeps only selected items that stay visible
5cript Oct 15, 2026
fdb122c
Explorer uploads and downloads share one flow in SideModel
5cript Oct 15, 2026
a074244
Renaming in the explorer is one flow in SideModel for both sides
5cript Oct 15, 2026
ebb9720
The side models share favorites, context menus and navigation
5cript Oct 15, 2026
9f31cb9
Drops on ".." go to the parent and drops on a directory link into it
5cript Oct 15, 2026
f238cc9
Navigating normalizes the path with the side's path type
5cript Oct 15, 2026
7811af2
Synchronize is offered only for directories with valid UTF-8 names
5cript Oct 15, 2026
3ff08ee
Listings move entries into explorer items; one sort comparator
5cript Oct 15, 2026
71171d2
New items and renames check names with the rules of their own side
5cript Oct 15, 2026
df09719
Delete confirmations list full paths; one local listing reply path
5cript Oct 15, 2026
cf6cc17
Side model callbacks run only while their owner exists
5cript Oct 15, 2026
c0bbf80
Archive names use fmt, the side models use algorithms
5cript Oct 15, 2026
31fdd86
File tracking bookkeeping lives in a hidden .nui-sftp-tracking directory
5cript Oct 15, 2026
65d5231
An efsw watch handle no longer keeps its instance's watcher alive
5cript Oct 15, 2026
b73eed5
File tracking bookkeeping gets pure, natively tested helpers
5cript Oct 15, 2026
f3bf350
A replaced confirm dialog runs its onClose without a button
5cript Oct 15, 2026
7645379
FileTracking::createInstance replies with the canonical directory
5cript Oct 15, 2026
e21013e
File tracking keeps one upload per changed path until it is recorded
5cript Oct 15, 2026
9eec487
The opener judges risky files with shared helpers and platform lists
5cript Oct 15, 2026
be76cf6
RpcFilesystem::open reads the risky-file policy from a snapshot
5cript Oct 15, 2026
3094f7e
The help texts name what the settings do
5cript Oct 15, 2026
f22ada9
Process::spawn builds the child with one launcher call
5cript Oct 15, 2026
c81f596
parseProgramOptions returns help and parse errors instead of printing
5cript Oct 15, 2026
7d4f29f
Dropped text is parsed by natively tested helpers
5cript Oct 15, 2026
0dd2d6e
Saving all terminals plans numbered names with NumberedSaves
5cript Oct 15, 2026
edfe090
Dialog headers are Title Case and texts say what the code does
5cript Oct 15, 2026
c28e54b
Toasts keep line breaks and show at most eight lines
5cript Oct 15, 2026
130a8e7
Backend helpers exposed for tests live in named namespaces
5cript Oct 15, 2026
38a2bbc
Remove unused includes of paths/native.hpp
5cript Oct 15, 2026
6f732a7
Build terminal exit callback names and node command lines with fmt
5cript Oct 15, 2026
f7cc92c
Doc comments on the changed code describe what it does now
5cript Oct 15, 2026
cb231bb
SftpSession call results are const locals
5cript Oct 15, 2026
04cf184
The German delete scan progress counts Elemente
5cript Oct 15, 2026
aaaaf03
Dead file tracking instances are cleaned up at application start
5cript Oct 15, 2026
7502b84
Add FileTracking::discardContent for declined content
5cript Oct 15, 2026
2a1b3ca
File tracking rows are found by instance; discard ends a watch
5cript Oct 15, 2026
7bf44e6
Declining to open a risky download removes the download
5cript Oct 15, 2026
c54ae06
The orphaned count of File Tracking follows Stop and declines
5cript Oct 15, 2026
898f1bd
Transfers inherit the source's mode when Inherit Permissions is unset
5cript Oct 15, 2026
c7318aa
Inherit Permissions shows as on while unset
5cript Oct 15, 2026
9f42b13
Transfers continue partial temp files when Try Continue is unset
5cript Oct 15, 2026
e2863da
Tests: The explorer filter keeps only selected items that stay visible
5cript Oct 15, 2026
f75a46a
Tests: Explorer uploads and downloads share one flow in SideModel
5cript Oct 15, 2026
1f4e8a7
Tests: Drops on ".." go to the parent and drops on a directory link i…
5cript Oct 15, 2026
f4d9250
Tests: Listings move entries into explorer items; one sort comparator
5cript Oct 15, 2026
99bdc96
Tests: New items and renames check names with the rules of their own …
5cript Oct 15, 2026
c91bd8a
Tests: File tracking bookkeeping lives in a hidden .nui-sftp-tracking…
5cript Oct 15, 2026
1a70436
Tests: An efsw watch handle no longer keeps its instance's watcher alive
5cript Oct 15, 2026
c94959e
Tests: File tracking bookkeeping gets pure, natively tested helpers
5cript Oct 15, 2026
b17ff9f
Tests: The opener judges risky files with shared helpers and platform…
5cript Oct 15, 2026
ed377e6
Tests: Process::spawn builds the child with one launcher call
5cript Oct 15, 2026
5d7486f
Tests: parseProgramOptions returns help and parse errors instead of p…
5cript Oct 15, 2026
7c85cd6
Tests: Dropped text is parsed by natively tested helpers
5cript Oct 15, 2026
184d30c
Tests: Saving all terminals plans numbered names with NumberedSaves
5cript Oct 15, 2026
bc9f35d
Tests: markAsDownloaded keeps content and sets each platform's mark
5cript Oct 15, 2026
c1e8a0f
Tests: Backend helpers exposed for tests live in named namespaces
5cript Oct 15, 2026
58a043f
Tests: Doc comments on the changed code describe what it does now
5cript Oct 15, 2026
3cf4c8c
Tests: The command store tests compile on Windows
5cript Oct 15, 2026
a2928f1
Tests: LocalPath JSON and execute-bit risk tests fit Windows
5cript Oct 15, 2026
d84f3fc
Tests: Dead file tracking instances are cleaned up at application start
5cript Oct 15, 2026
252cf35
Tests: Add FileTracking::discardContent for declined content
5cript Oct 15, 2026
a05f7f2
Tests: Transfers inherit the source's mode when Inherit Permissions i…
5cript Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ add_executable(${PROJECT_NAME})
target_link_libraries(${PROJECT_NAME} PUBLIC core-target)

if (WIN32)
# A GUI program: a console program lives in a console window and exits with it, closing that window or a crash
# of Windows Terminal ended the app silently. --console attaches to the console it was started from.
# A GUI program, not tied to a console window. --console, the help and parse errors use the console it was
# started from.
set_target_properties(${PROJECT_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
include("${CMAKE_CURRENT_LIST_DIR}/_cmake/windows/icon.cmake")
file(TO_CMAKE_PATH "${NUI_SFTP_ICO}" NUI_SFTP_ICO_FORWARD)
Expand Down Expand Up @@ -205,6 +205,7 @@ else()
add_subdirectory(shared_data/benchmark/shared_data)
add_subdirectory(tar-archive/test/tar_archive)
add_subdirectory(command-store/test/command-store)
add_subdirectory(frontend/test/frontend)
add_test(
NAME NarrowPathConversions
COMMAND "${CMAKE_COMMAND}" "-DSOURCE_DIR=${CMAKE_SOURCE_DIR}" -P "${CMAKE_SOURCE_DIR}/_cmake/check_narrow_path_text.cmake"
Expand Down
73 changes: 73 additions & 0 deletions backend/include/backend/file_tracking/instance_bookkeeping.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#pragma once

#include <nlohmann/json.hpp>

#include <expected>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>

namespace FileTracking
{
/**
* @brief The hidden directory inside an instance directory that holds the instance lock and the metadata, so
* they never share a name with the tracked content next to them.
*/
inline constexpr std::string_view bookkeepingDirectoryName{".nui-sftp-tracking"};

/**
* @brief The lock file and the metadata file of one instance directory.
*/
struct BookkeepingPaths
{
std::filesystem::path lock;
std::filesystem::path metadata;
};

/**
* @brief Where an instance keeps its lock and metadata: inside the bookkeeping directory.
* @param instanceDirectory The instance directory.
*/
BookkeepingPaths bookkeepingPaths(std::filesystem::path const& instanceDirectory);

/**
* @brief Where builds without the bookkeeping directory keep them: "<instance id>.lock" and "metadata.json"
* directly in the instance directory. They are still read, and the metadata is mirrored into them.
* @param instanceDirectory The instance directory, named after the instance id.
*/
BookkeepingPaths legacyBookkeepingPaths(std::filesystem::path const& instanceDirectory);

/**
* @brief Creates the bookkeeping directory of @p instanceDirectory (and the instance directory). On Windows it
* gets the hidden attribute, elsewhere the leading dot hides it.
*/
std::expected<void, std::string> createBookkeepingDirectory(std::filesystem::path const& instanceDirectory);

/**
* @brief Whether @p path is the bookkeeping directory of @p instanceDirectory or lies inside it. Compares
* lexically, so both paths must be spelled alike (both canonical, for one).
*/
bool isBookkeepingPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path);

/**
* @brief The lock file of @p instanceDirectory: the one in the bookkeeping directory, else the legacy one.
* @return Nothing when neither exists, the directory is then no instance directory.
*/
std::optional<std::filesystem::path> existingLockFile(std::filesystem::path const& instanceDirectory);

/**
* @brief Reads the metadata of @p instanceDirectory: the file in the bookkeeping directory, else the legacy one
* of a legacy instance (a legacy lock and no current one; elsewhere "metadata.json" is tracked content).
* @return Nothing when neither exists, an error when the file cannot be read or parsed.
*/
std::expected<std::optional<nlohmann::json>, std::string>
readMetadata(std::filesystem::path const& instanceDirectory);

/**
* @brief Writes @p metadata into the bookkeeping directory, creating it when missing, and into the legacy file of
* a legacy instance when it has one, so builds that only know the legacy location read the same.
*/
std::expected<void, std::string>
writeMetadata(std::filesystem::path const& instanceDirectory, nlohmann::json const& metadata);
}
12 changes: 6 additions & 6 deletions backend/include/backend/file_tracking/instance_watch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ namespace FileTracking
/**
* @brief Move-only RAII handle representing one efsw directory watch.
*
* Destroying or moving-from an InstanceWatch automatically removes the
* underlying efsw watch via efsw::FileWatcher::removeWatch().
* Instances of this type are produced by
* Destroying an InstanceWatch or assigning another one to it removes the
* underlying efsw watch via efsw::FileWatcher::removeWatch(). It does not keep
* the watcher alive: once its instance destroyed the watcher there is nothing
* left to remove. Instances of this type are produced by
* TemporaryDirectoryInstance::addWatch().
*/
class InstanceWatch
Expand All @@ -26,13 +27,12 @@ namespace FileTracking
/**
* @brief Construct a valid watch handle.
*
* @param watcher Shared owning pointer to the efsw::FileWatcher that
* registered this watch.
* @param watcher The efsw::FileWatcher that registered this watch.
* @param watchId The watch ID returned by efsw::FileWatcher::addWatch().
* @param path The directory path being watched.
*/
InstanceWatch(
std::shared_ptr<efsw::FileWatcher> watcher,
std::weak_ptr<efsw::FileWatcher> watcher,
efsw::WatchID watchId,
std::filesystem::path path
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
#include <backend/rpc_helper.hpp>

#include <boost/asio/any_io_executor.hpp>
#include <nlohmann/json.hpp>

#include <chrono>
#include <expected>
#include <filesystem>
#include <map>
#include <memory>
Expand All @@ -29,14 +31,47 @@ namespace FileTracking
std::filesystem::path instanceDir;
};

// -------------------------------------------------------------------------
namespace InstanceManagerDetail
{
/**
* @brief What the reply of FileTracking::fileStates lists: every regular file below @p instanceDirectory as
* {path, size, mtime} (seconds) and every directory as {path, directory: true}, paths canonical and
* UTF-8. Symbolic links and the bookkeeping directory are left out.
*/
nlohmann::json fileStatesOf(std::filesystem::path const& instanceDirectory);

/**
* @brief @p path as content of @p instanceDirectory that may be removed: its parent directory resolved,
* its last component kept as it is, so a link there is removed itself.
* @return An error when @p path is not below @p instanceDirectory once its parent is resolved, is the
* instance directory itself or lies in the bookkeeping directory.
*/
std::expected<std::filesystem::path, std::string>
removableContentPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path);

/**
* @brief Whether @p instanceDirectory holds anything besides its bookkeeping directory. A directory that
* cannot be read counts as holding content.
*/
bool hasContent(std::filesystem::path const& instanceDirectory);
}

/**
* @brief What TempDirInstanceManager::discardContent did with the instance after removing the content.
*/
enum class DiscardOutcome
{
InstanceKept,
InstanceDestroyed,
};

/**
* @brief Owns and tracks all TemporaryDirectoryInstance objects for a session.
*
* Inherits RpcHelper::StrandRpc so that all RPC handlers it registers run on
* the shared strand. The manager also owns the dead-instance cleanup policy
* and exposes it both as a C++ API and as RPC endpoints.
* the shared strand. The manager also owns the dead-instance cleanup policy:
* the retention scan runs once at start (see scheduleStartupCleanup), the
* frontend's Clean Up removes every orphaned directory at once (forceCleanup).
*
* ### Registered RPC endpoints
* All names are prefixed with @c "FileTracking::".
Expand All @@ -47,7 +82,8 @@ namespace FileTracking
* | destroyInstance | instanceId: string | {success} |
* | listInstances | — | {success, instances: [...]} |
* | getInstanceInfo | instanceId: string | {success, instanceId, instanceDir} |
* | manualCleanup | — | {success} |
* | fileStates | instanceId: string | {success, files: [...]}, see InstanceManagerDetail::fileStatesOf |
* | discardContent | instanceId: string, path: string | {success, instanceDestroyed: bool}, see discardContent |
* | countOrphaned | — | {success, count: int} |
* | forceCleanup | — | {success, removed: int} |
*
Expand Down Expand Up @@ -94,6 +130,13 @@ namespace FileTracking
*/
void registerRpc();

/**
* @brief Runs manualCleanup() once on the strand, for the instance directories that earlier runs left.
*
* Called once at start, after registerRpc(), so the scan runs before the frontend's first request.
*/
void scheduleStartupCleanup();

// ---- C++ API --------------------------------------------------------

/**
Expand All @@ -106,14 +149,27 @@ namespace FileTracking
TemporaryDirectoryInstance* createInstance();

/**
* @brief Destroy and remove the instance with the given ID.
* @brief Remove the watches of the instance with the given ID, then destroy it.
*
* If the instance does not exist this is a no-op.
*
* @param instanceId ID returned by TemporaryDirectoryInstance::instanceId().
*/
void destroyInstance(std::string const& instanceId);

/**
* @brief Removes @p path, content of the instance @p instanceId that the user decided not to keep, after
* ending the instance's watches, so the removal is reported as no change. A link is removed itself,
* never followed. When nothing else is left in the instance directory, the instance is destroyed
* and, unlike destroyInstance(), its directory is removed as well (bookkeeping included, after its
* lock is released), so no orphan stays behind.
*
* @return An error when the instance does not exist, @p path is no content of it (see
* InstanceManagerDetail::removableContentPath) or cannot be removed.
*/
std::expected<DiscardOutcome, std::string>
discardContent(std::string const& instanceId, std::filesystem::path const& path);

/**
* @brief Look up an instance by ID.
*
Expand Down
8 changes: 5 additions & 3 deletions backend/include/backend/file_tracking/temp_dir_instancing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ namespace FileTracking
* @brief Manages one temporary directory instance used for file-change tracking.
*
* On construction a UUID-named subdirectory is created under @p tempRootDir,
* an exclusive instance lock file is acquired inside it, and a metadata file is
* written.
* and an exclusive instance lock file is acquired and a metadata file written
* in its bookkeeping directory (see bookkeepingPaths()). Events inside the
* bookkeeping directory are not forwarded.
*
* On destruction the instance does **not** delete its directory. It writes a
* "no longer alive" timestamp into the metadata file so a future scan (performed
Expand All @@ -56,7 +57,8 @@ namespace FileTracking
* "action": "Added" | "Modified" | "Deleted" | "Moved",
* "directory": "/abs/path/to/dir/",
* "filename": "changed-file.txt",
* "oldFilename": "previous-name.txt" // only populated for Moved
* "oldFilename": "previous-name.txt", // only populated for Moved
* "isDirectory": false // the changed path is a directory now
* }
* @endcode
*
Expand Down
4 changes: 4 additions & 0 deletions backend/include/backend/main.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class Main
Nui::Window window_;
Nui::RpcHub hub_;
std::unique_ptr<Opener> opener_;
/**
* @brief The saved openRiskyFiles setting, written on the thread that saves the state and read by RpcFilesystem.
*/
std::atomic<Persistence::RiskyFilePolicy> riskyFilePolicy_;
std::unique_ptr<RpcFilesystem> rpcFilesystem_;
std::unique_ptr<RpcSystem> rpcSystem_;
ProcessStore processes_;
Expand Down
88 changes: 88 additions & 0 deletions backend/include/backend/opener_risk.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#pragma once

#include <array>
#include <filesystem>
#include <optional>
#include <span>
#include <string>
#include <string_view>

namespace OpenerDetail
{
/**
* @brief Types Windows runs, installs or follows on top of what AssocIsDangerous reports.
*/
inline constexpr std::array<std::string_view, 12> windowsRunnableExtensions{
".jar",
".py",
".pyw",
".pyz",
".application",
".appref-ms",
".appx",
".msix",
".appxbundle",
".msixbundle",
".appinstaller",
".settingcontent-ms",
};

/**
* @brief Types LaunchServices runs, installs or follows instead of showing them.
*/
inline constexpr std::array<std::string_view, 15> macRunnableExtensions{
".command",
".tool",
".terminal",
".pkg",
".mpkg",
".workflow",
".scpt",
".applescript",
".jar",
".webloc",
".inetloc",
".fileloc",
".prefpane",
".saver",
".mobileconfig",
};

/**
* @brief Types a Linux desktop starts as programs or installs instead of showing them.
*/
inline constexpr std::array<std::string_view, 9> linuxRunnableExtensions{
".desktop",
".appimage",
".run",
".jar",
".deb",
".rpm",
".flatpakref",
".flatpakrepo",
".snap",
};

/**
* @brief True when the extension of @p path, compared ignoring ASCII case, is one of @p extensions (each written
* in lower case with its dot).
*/
bool hasExtensionIn(std::filesystem::path const& path, std::span<std::string_view const> extensions);

/**
* @brief True when owner, group or others may execute.
*/
bool hasExecuteBit(std::filesystem::perms permissions);

/**
* @brief True when @p path starts like a program: the ELF magic or a "#!" interpreter line.
*/
bool hasExecutableContent(std::filesystem::path const& path);

/**
* @brief The Linux classification of @p path, see Opener::riskOf. A file with an execute bit counts as a program
* only when its content is one, since mounts without permissions (FAT, NTFS, SMB) mark every file
* executable. A symlink is judged by its own name and by its target's.
*/
std::optional<std::string> linuxRiskOf(std::filesystem::path const& path);
}
21 changes: 19 additions & 2 deletions backend/include/backend/program_options.hpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
#pragma once

#include <optional>
#include <expected>
#include <string>

struct ProgramOptions
{
bool enableDevTools = false;
};

std::optional<ProgramOptions> parseProgramOptions(int argc, char const* const* argv);
/**
* @brief What the program prints instead of starting: the help, or a parse error followed by the help.
*/
struct ProgramOptionsExit
{
std::string message;
/**
* @brief True for a parse error, which goes to the error stream and exits with 1.
*/
bool isError = false;
};

/**
* @brief The options of the command line, or what to print and exit with when the help is requested or an argument
* is not understood.
*/
std::expected<ProgramOptions, ProgramOptionsExit> parseProgramOptions(int argc, char const* const* argv);
Loading
Loading