Implement URI handler for local file editing

Signed-off-by: alex-z <blackslayer4@gmail.com>
This commit is contained in:
alex-z 2022-07-19 13:05:20 +03:00
parent 4f85f7a45d
commit d42d3c057f
18 changed files with 269 additions and 29 deletions

View file

@ -5,6 +5,7 @@ set( APPLICATION_DOMAIN "nextcloud.com" )
set( APPLICATION_VENDOR "Nextcloud GmbH" )
set( APPLICATION_UPDATE_URL "https://updates.nextcloud.org/client/" CACHE STRING "URL for updater" )
set( APPLICATION_HELP_URL "" CACHE STRING "URL for the help menu" )
set( APPLICATION_URI_HANDLER_SCHEME "nc")
if(APPLE AND APPLICATION_NAME STREQUAL "Nextcloud" AND EXISTS "${CMAKE_SOURCE_DIR}/theme/colored/Nextcloud-macOS-icon.svg")
set( APPLICATION_ICON_NAME "Nextcloud-macOS" )

View file

@ -190,6 +190,19 @@
<!-- Property to disable update checks -->
<RegistryValue Type="integer" Name="skipUpdateCheck" Value="[SKIPAUTOUPDATE]" />
</RegistryKey>
</Component>
<!-- Register URI handler -->
<Component Id="RegistryUriHandler" Guid="*" Win64="$(var.PlatformWin64)">
<RegistryKey Root="HKCU" Key="Software\Classes\$(var.AppCommandOpenUrlScheme)" ForceCreateOnInstall="yes" ForceDeleteOnUninstall="yes">
<RegistryValue Type="string" Value="URL:$(var.AppName) Protocol" />
<RegistryValue Type="string" Name="URL Protocol" Value="" />
</RegistryKey>
<RegistryKey Root="HKCU" Key="Software\Classes\$(var.AppCommandOpenUrlScheme)\DefaultIcon" ForceCreateOnInstall="yes" ForceDeleteOnUninstall="yes">
<RegistryValue Type="string" Value="[INSTALLDIR]$(var.AppExe)" />
</RegistryKey>
<RegistryKey Root="HKCU" Key="Software\Classes\$(var.AppCommandOpenUrlScheme)\shell\open\command" ForceCreateOnInstall="yes" ForceDeleteOnUninstall="yes">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.AppExe)&quot; &quot;%1&quot;" />
</RegistryKey>
</Component>
</DirectoryRef>
@ -200,6 +213,7 @@
<ComponentRef Id="RegistryVersionInfo" />
<ComponentRef Id="RegistryDefaultSettings" />
<ComponentRef Id="RegistryUriHandler" />
<Feature Id="ShellExtensions" Title="Integration for Windows Explorer"
Description="This feature requires a reboot." >

View file

@ -28,6 +28,8 @@
<?define AppHelpLink = "https://@APPLICATION_DOMAIN@/" ?>
<?define AppInfoLink = "$(var.AppHelpLink)" ?>
<?define AppCommandOpenUrlScheme = "@APPLICATION_URI_HANDLER_SCHEME@" ?>
<!-- Custom license: To use it, also remove the "Skip the license page" stuff in the <UI> section
and uncomment <WixVariable Id="WixUILicenseRtf"...

View file

@ -76,6 +76,17 @@
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>@APPLICATION_NAME@ Edit Locally</string>
<key>CFBundleURLSchemes</key>
<array>
<string>@APPLICATION_URI_HANDLER_SCHEME@</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -32,6 +32,7 @@
#cmakedefine APPLICATION_OCSP_STAPLING_ENABLED "@APPLICATION_OCSP_STAPLING_ENABLED@"
#cmakedefine APPLICATION_FORBID_BAD_SSL "@APPLICATION_FORBID_BAD_SSL@"
#define APPLICATION_DOTVIRTUALFILE_SUFFIX "." APPLICATION_VIRTUALFILE_SUFFIX
#define APPLICATION_URI_HANDLER_SCHEME "@APPLICATION_URI_HANDLER_SCHEME@"
#cmakedefine01 ENFORCE_VIRTUAL_FILES_SYNC_FOLDER
#cmakedefine DO_NOT_USE_PROXY "@DO_NOT_USE_PROXY@"

View file

@ -440,3 +440,23 @@ Files that must be removed from the local storage only, need to be dehydrated vi
.. note::
* End-to-end Encryption works with Virtual Files (VFS) but only on a per-folder level. Folders with E2EE can be made available offline in their entirety, but the individual files in them can not be retrieved on demand. This is mainly due to two technical reasons. First, the Windows VFS API is not designed for handling encrypted files. Second, while the VFS is designed to deal mostly with large files, E2EE is mostly recommended for use with small files as encrypting and decrypting large files puts large demands on the computer infrastructure.
Local file editing
------------------
The Nextcloud desktop GUI client supports local editing when opening a URL that starts with
a scheme ``nc://`` followed by an ``open`` command, followed by a user email (with port when needed),
followed by file path relative to remote root.
Examples of URLs that Nextcloud can handle if the user email and a path to a file is correct:
- ``nc://open/admin@example.cloud:8080/Photos/lovely.jpg``
- ``nc://open/user@example.cloud/Photos/lovely.jpg``
- ``nc://open/user@example.cloud/Documents/sheets/report.xlsx``
- ``nc://open/user@example.cloud/Documents/docs/document.docx``
.. note::
* All the file paths that begin after user email are relative to remote root (``/``).
* The server is responsible for generating a correct URL that a user then clicks to edit file locally.
* The Nextcloud desktop client is registered in macOS, Linux, and Windows as a custom URI handler for the ``nc://`` scheme.
* The URL is parsed and validated by Nextcloud desktop client, so, opening an incorrectly formatted URL will not have any effect.
* The port after user email is necessary if the default :80 or :443 is not used. The rule of thumb is to always have a port added if you need it when accessing your server via Web UI

View file

@ -1,14 +1,14 @@
[Desktop Entry]
Categories=Utility;X-SuSE-SyncUtility;
Type=Application
Exec=@APPLICATION_EXECUTABLE@
Exec=@APPLICATION_EXECUTABLE@ %u
Name=@APPLICATION_NAME@ Desktop
Comment=@APPLICATION_NAME@ desktop synchronization client
GenericName=Folder Sync
Icon=@APPLICATION_ICON_NAME@
Keywords=@APPLICATION_NAME@;syncing;file;sharing;
X-GNOME-Autostart-Delay=3
MimeType=application/vnd.@APPLICATION_EXECUTABLE@;
MimeType=application/vnd.@APPLICATION_EXECUTABLE@;x-scheme-handler/@APPLICATION_URI_HANDLER_SCHEME@;
Actions=Quit;
# Translations

View file

@ -33,7 +33,6 @@
#include <qtlockedfile.h>
#include <QDir>
#include <QFileOpenEvent>
#include <QSharedMemory>
#include <QWidget>
@ -119,16 +118,6 @@ QtSingleApplication::~QtSingleApplication()
lockfile.unlock();
}
bool QtSingleApplication::event(QEvent *event)
{
if (event->type() == QEvent::FileOpen) {
auto *foe = static_cast<QFileOpenEvent*>(event);
emit fileOpenRequest(foe->file());
return true;
}
return QApplication::event(event);
}
bool QtSingleApplication::isRunning(qint64 pid)
{
if (pid == -1) {

View file

@ -50,7 +50,6 @@ public:
void setActivationWindow(QWidget* aw, bool activateOnMessage = true);
QWidget* activationWindow() const;
bool event(QEvent *event) override;
QString applicationId() const;
void setBlock(bool value);

View file

@ -247,6 +247,11 @@ namespace Utility {
*/
OCSYNC_EXPORT QString getCurrentUserName();
/**
* @brief Registers the desktop app as a handler for a custom URI to enable local editing
*/
OCSYNC_EXPORT void registerUriHandlerForLocalEditing();
#ifdef Q_OS_WIN
OCSYNC_EXPORT bool registryKeyExists(HKEY hRootKey, const QString &subKey);
OCSYNC_EXPORT QVariant registryGetKeyValue(HKEY hRootKey, const QString &subKey, const QString &valueName);

View file

@ -141,4 +141,6 @@ QString Utility::getCurrentUserName()
return {};
}
void Utility::registerUriHandlerForLocalEditing() { /* URI handler is registered via MacOSXBundleInfo.plist.in */ }
} // namespace OCC

View file

@ -19,6 +19,7 @@
#include <QStandardPaths>
#include <QtGlobal>
#include <QProcess>
namespace OCC {
@ -113,4 +114,26 @@ QString Utility::getCurrentUserName()
return {};
}
void Utility::registerUriHandlerForLocalEditing()
{
const auto appImagePath = qEnvironmentVariable("APPIMAGE");
const auto runningInsideAppImage = !appImagePath.isNull() && QFile::exists(appImagePath);
if (!runningInsideAppImage) {
// only register x-scheme-handler if running inside appImage
return;
}
// mirall.desktop.in must have an x-scheme-handler mime type specified
const QString desktopFileName = QLatin1String(LINUX_APPLICATION_ID) + QLatin1String(".desktop");
QProcess process;
const QStringList args = {
QLatin1String("default"),
desktopFileName,
QStringLiteral("x-scheme-handler/%1").arg(QStringLiteral(APPLICATION_URI_HANDLER_SCHEME))
};
process.start(QStringLiteral("xdg-mime"), args, QIODevice::ReadOnly);
process.waitForFinished();
}
} // namespace OCC

View file

@ -448,6 +448,8 @@ QString Utility::getCurrentUserName()
return QString::fromWCharArray(username);
}
void Utility::registerUriHandlerForLocalEditing() { /* URI handler is registered via Nextcloud.wxs */ }
Utility::NtfsPermissionLookupRAII::NtfsPermissionLookupRAII()
{
qt_ntfs_permission_lookup++;

View file

@ -406,6 +406,8 @@ Application::Application(int &argc, char **argv)
connect(_gui.data(), &ownCloudGui::isShowingSettingsDialog, this, &Application::slotGuiIsShowingSettings);
_gui->createTray();
handleEditLocallyFromOptions();
}
Application::~Application()
@ -572,6 +574,8 @@ void Application::slotParseMessage(const QString &msg, QObject *)
qApp->quit();
}
handleEditLocallyFromOptions();
} else if (msg.startsWith(QLatin1String("MSG_SHOWMAINDIALOG"))) {
qCInfo(lcApplication) << "Running for" << _startedAt.elapsed() / 1000.0 << "sec";
if (_startedAt.elapsed() < 10 * 1000) {
@ -647,7 +651,17 @@ void Application::parseOptions(const QStringList &options)
} else if (option.endsWith(QStringLiteral(APPLICATION_DOTVIRTUALFILE_SUFFIX))) {
// virtual file, open it after the Folder were created (if the app is not terminated)
QTimer::singleShot(0, this, [this, option] { openVirtualFile(option); });
} else {
} else if (option.startsWith(QStringLiteral(APPLICATION_URI_HANDLER_SCHEME "://open"))) {
// see the section Local file editing of the Architecture page of the user documenation
_editFileLocallyUrl = QUrl::fromUserInput(option);
if (!_editFileLocallyUrl.isValid()) {
_editFileLocallyUrl.clear();
const auto errorParsingLocalFileEditingUrl = QStringLiteral("The supplied url for local file editing '%1' is invalid!").arg(option);
qCInfo(lcApplication) << errorParsingLocalFileEditingUrl;
showHint(errorParsingLocalFileEditingUrl.toStdString());
}
}
else {
showHint("Unrecognized option '" + option.toStdString() + "'");
}
}
@ -728,6 +742,32 @@ void Application::setHelp()
_helpOnly = true;
}
void Application::handleEditLocallyFromOptions()
{
if (!_editFileLocallyUrl.isValid()) {
return;
}
handleEditLocally(_editFileLocallyUrl);
_editFileLocallyUrl.clear();
}
void Application::handleEditLocally(const QUrl &url) const
{
auto pathSplit = url.path().split('/', Qt::SkipEmptyParts);
if (pathSplit.size() < 2) {
qCWarning(lcApplication) << "Invalid URL for file local editing: " + pathSplit.join('/');
return;
}
// for a sample URL "nc://open/admin@nextcloud.lan:8080/Photos/lovely.jpg", QUrl::path would return "admin@nextcloud.lan:8080/Photos/lovely.jpg"
const auto accountDisplayName = pathSplit.takeFirst();
const auto fileRemotePath = pathSplit.join('/');
FolderMan::instance()->editFileLocally(accountDisplayName, fileRemotePath);
}
QString substLang(const QString &lang)
{
// Map the more appropriate script codes
@ -855,15 +895,26 @@ void Application::tryTrayAgain()
bool Application::event(QEvent *event)
{
#ifdef Q_OS_MAC
if (event->type() == QEvent::FileOpen) {
QFileOpenEvent *openEvent = static_cast<QFileOpenEvent *>(event);
qCDebug(lcApplication) << "QFileOpenEvent" << openEvent->file();
// virtual file, open it after the Folder were created (if the app is not terminated)
QString fn = openEvent->file();
QTimer::singleShot(0, this, [this, fn] { openVirtualFile(fn); });
const auto openEvent = static_cast<QFileOpenEvent *>(event);
qCDebug(lcApplication) << "macOS: Received a QFileOpenEvent";
if(!openEvent->file().isEmpty()) {
qCDebug(lcApplication) << "QFileOpenEvent" << openEvent->file();
// virtual file, open it after the Folder were created (if the app is not terminated)
const auto fn = openEvent->file();
QTimer::singleShot(0, this, [this, fn] { openVirtualFile(fn); });
} else if (!openEvent->url().isEmpty() && openEvent->url().isValid()) {
// On macOS, Qt does not handle receiving a custom URI as it does on other systems (as an application argument).
// Instead, it sends out a QFileOpenEvent. We therefore need custom handling for our URI handling on macOS.
qCInfo(lcApplication) << "macOS: Opening local file for editing: " << openEvent->url();
handleEditLocally(openEvent->url());
} else {
const auto errorParsingLocalFileEditingUrl = QStringLiteral("The supplied url for local file editing '%1' is invalid!").arg(openEvent->url().toString());
qCInfo(lcApplication) << errorParsingLocalFileEditingUrl;
showHint(errorParsingLocalFileEditingUrl.toStdString());
}
}
#endif
return SharedTools::QtSingleApplication::event(event);
}

View file

@ -72,6 +72,8 @@ public:
ownCloudGui *gui() const;
bool event(QEvent *event) override;
public slots:
// TODO: this should not be public
void slotownCloudWizardDone(int);
@ -85,11 +87,12 @@ public slots:
/// Attempt to show() the tray icon again. Used if no systray was available initially.
void tryTrayAgain();
void handleEditLocally(const QUrl &url) const;
protected:
void parseOptions(const QStringList &);
void setupTranslations();
void setupLogging();
bool event(QEvent *event) override;
signals:
void folderRemoved();
@ -109,6 +112,8 @@ protected slots:
private:
void setHelp();
void handleEditLocallyFromOptions();
/**
* Maybe a newer version of the client was used with this config file:
* if so, backup, confirm with user and remove the config that can't be read.
@ -135,6 +140,7 @@ private:
bool _userTriggeredConnect;
bool _debugMode;
bool _backgroundMode;
QUrl _editFileLocallyUrl;
ClientProxy _proxy;

View file

@ -17,23 +17,72 @@
#import <Foundation/NSAutoreleasePool.h>
#import <AppKit/NSApplication.h>
#include "application.h"
/* In theory, we should be able to just capture QFileOpenEvents
* when we open our custom URLs in our Application class and be
* done with it, but in practice the QFileOpenEvent often doesn't
* get sent for our URLs. We have this in place to work around
* the issue.
*
* This class sets a callback selector on URL-related events
* before the application is fully done launching. This lets us
* properly receive and process "open url" events even if the
* client was closed when these events were sent. */
@interface URLEventHandler : NSObject
@end
@implementation URLEventHandler
- (id)init {
self = [super init];
if (self) {
NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
[defaultCenter addObserver:self
selector:@selector(applicationWillFinishLaunching:)
name:NSApplicationWillFinishLaunchingNotification
object:nil];
}
return self;
}
- (void)applicationWillFinishLaunching:(NSNotification *)aNotification {
[[NSAppleEventManager sharedAppleEventManager] setEventHandler:self
andSelector:@selector(handleURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass
andEventID:kAEGetURL];
}
- (void)handleURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent
{
NSURL* url = [NSURL URLWithString:[[event paramDescriptorForKeyword:keyDirectObject] stringValue]];
const auto app = qobject_cast<OCC::Application *>(QApplication::instance());
const auto qtUrl = QUrl::fromNSURL(url);
app->handleEditLocally(qtUrl);
}
@end
namespace OCC {
namespace Mac {
class CocoaInitializer::Private {
public:
public:
NSAutoreleasePool* autoReleasePool;
URLEventHandler* handler;
};
CocoaInitializer::CocoaInitializer() {
d = new CocoaInitializer::Private();
NSApplicationLoad();
d->autoReleasePool = [[NSAutoreleasePool alloc] init];
d = new CocoaInitializer::Private();
d->handler = [[URLEventHandler alloc] init];
NSApplicationLoad();
d->autoReleasePool = [[NSAutoreleasePool alloc] init];
}
CocoaInitializer::~CocoaInitializer() {
[d->autoReleasePool release];
delete d;
[d->autoReleasePool release];
delete d;
}
} // namespace Mac

View file

@ -36,6 +36,8 @@
#include <QMutableSetIterator>
#include <QSet>
#include <QNetworkProxy>
#include <QDesktopServices>
#include <QtConcurrent>
static const char versionC[] = "version";
static const int maxFoldersVersion = 1;
@ -163,6 +165,8 @@ void FolderMan::registerFolderWithSocketApi(Folder *folder)
int FolderMan::setupFolders()
{
Utility::registerUriHandlerForLocalEditing();
unloadAndDeleteAllFolders();
QStringList skipSettingsKeys;
@ -1402,6 +1406,64 @@ void FolderMan::setDirtyNetworkLimits()
}
}
void FolderMan::editFileLocally(const QString &accountDisplayName, const QString &relPath)
{
const auto showError = [this](const OCC::AccountStatePtr accountState, const QString &errorMessage, const QString &subject) {
if (accountState && accountState->account()) {
const auto foundFolder = std::find_if(std::cbegin(map()), std::cend(map()), [accountState](const auto &folder) {
return accountState->account()->davUrl() == folder->remoteUrl();
});
if (foundFolder != std::cend(map())) {
(*foundFolder)->syncEngine().addErrorToGui(SyncFileItem::SoftError, errorMessage, subject);
}
}
// to make sure the error is not missed, show a message box in addition
const auto messageBox = new QMessageBox;
messageBox->setAttribute(Qt::WA_DeleteOnClose);
messageBox->setText(errorMessage);
messageBox->setInformativeText(subject);
messageBox->setIcon(QMessageBox::Warning);
messageBox->addButton(QMessageBox::StandardButton::Ok);
messageBox->show();
messageBox->activateWindow();
messageBox->raise();
};
const auto accountFound = AccountManager::instance()->account(accountDisplayName);
if (!accountFound) {
qCWarning(lcFolderMan) << "Could not find an account " << accountDisplayName << " to edit file " << relPath << " locally.";
showError(accountFound, tr("Could not find an account for local editing"), accountDisplayName);
return;
}
const auto foundFiles = findFileInLocalFolders(relPath, accountFound->account());
if (foundFiles.isEmpty()) {
for (const auto &folder : map()) {
bool result = false;
const auto excludedThroughSelectiveSync = folder->journalDb()->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &result);
for (const auto &excludedPath : excludedThroughSelectiveSync) {
if (relPath.startsWith(excludedPath)) {
showError(accountFound, tr("Could not find a file for local editing. Make sure it is not excluded via selective sync."), relPath);
return;
}
}
}
showError(accountFound, tr("Could not find a file for local editing. Make sure its path is valid and it is synced locally."), relPath);
return;
}
// In case the VFS mode is enabled and a file is not yet hydrated, we must call QDesktopServices::openUrl from a separate thread, or, there will be a freeze.
// To avoid searching for a specific folder and checking if the VFS is enabled - we just always call it from a separate thread.
QtConcurrent::run([foundFiles] {
QDesktopServices::openUrl(QUrl::fromLocalFile(foundFiles.first()));
});
}
void FolderMan::trayOverallStatus(const QList<Folder *> &folders,
SyncResult::Status *status, bool *unresolvedConflicts)
{

View file

@ -202,6 +202,9 @@ public:
void setDirtyProxy();
void setDirtyNetworkLimits();
/** opens a file with default app, if the file is present **/
void editFileLocally(const QString &accountDisplayName, const QString &relPath);
signals:
/**
* signal to indicate a folder has changed its sync state.