From 289a641d74bab0fbe1ad4e43c081432028a77446 Mon Sep 17 00:00:00 2001 From: allexzander Date: Fri, 2 Sep 2022 12:48:52 +0300 Subject: [PATCH] VFS Windows: Display the sharing state and lock state in the 'Status' column of Windows Explorer Signed-off-by: allexzander --- CMakeLists.txt | 4 + admin/win/msi/RegistryCleanup.vbs.in | 12 +- cmake/modules/ECMAddAppIcon.cmake | 28 +- cmake/modules/GenerateIconsUtils.cmake | 58 +++ config.h.in | 4 + src/common/shellextensionutils.cpp | 1 - src/common/shellextensionutils.h | 7 +- src/common/syncjournaldb.cpp | 26 +- src/common/syncjournalfilerecord.h | 2 + src/gui/CMakeLists.txt | 61 +-- src/gui/ocsjob.cpp | 21 +- src/gui/ocsjob.h | 7 +- src/gui/ocssharejob.cpp | 11 +- src/gui/ocssharejob.h | 4 +- src/gui/shellextensionsserver.cpp | 270 +++++++++++++- src/gui/shellextensionsserver.h | 27 ++ src/gui/socketapi/socketapi.cpp | 3 + src/libsync/bulkpropagatorjob.cpp | 2 + src/libsync/discovery.cpp | 10 + src/libsync/propagateremotemkdir.cpp | 2 + src/libsync/syncfileitem.cpp | 4 + src/libsync/syncfileitem.h | 3 + src/libsync/vfs/cfapi/cfapiwrapper.cpp | 4 +- src/libsync/vfs/cfapi/shellext/CMakeLists.txt | 183 ++++++++- .../cfapi/shellext/CustomStateProvider.idl | 21 ++ .../shellext/configvfscfapishellext.h.in | 20 + .../cfapi/shellext/customstateprovider.cpp | 104 ++++++ .../vfs/cfapi/shellext/customstateprovider.h | 46 +++ .../cfapi/shellext/customstateprovideripc.cpp | 104 ++++++ .../cfapi/shellext/customstateprovideripc.h | 43 +++ src/libsync/vfs/cfapi/shellext/dllmain.cpp | 19 +- src/libsync/vfs/cfapi/shellext/ipccommon.cpp | 50 +++ src/libsync/vfs/cfapi/shellext/ipccommon.h | 21 ++ .../vfs/cfapi/shellext/thumbnailprovider.cpp | 7 + .../vfs/cfapi/shellext/thumbnailprovider.h | 2 +- .../cfapi/shellext/thumbnailprovideripc.cpp | 27 +- src/libsync/vfs/cfapi/vfs_cfapi.cpp | 49 ++- test/CMakeLists.txt | 2 +- test/testcfapishellextensionsipc.cpp | 347 ++++++++++++++++-- .../cfapishellext_custom_states/0-locked.svg | 1 + .../cfapishellext_custom_states/1-shared.svg | 1 + .../1024-0-locked.png | Bin 0 -> 12483 bytes .../1024-1-shared.png | Bin 0 -> 20840 bytes .../128-0-locked.png | Bin 0 -> 1064 bytes .../128-1-shared.png | Bin 0 -> 1424 bytes .../24-0-locked.png | Bin 0 -> 325 bytes .../24-1-shared.png | Bin 0 -> 405 bytes .../256-0-locked.png | Bin 0 -> 2257 bytes .../256-1-shared.png | Bin 0 -> 3666 bytes .../32-0-locked.png | Bin 0 -> 314 bytes .../32-1-shared.png | Bin 0 -> 379 bytes .../40-0-locked.png | Bin 0 -> 439 bytes .../40-1-shared.png | Bin 0 -> 566 bytes .../48-0-locked.png | Bin 0 -> 459 bytes .../48-1-shared.png | Bin 0 -> 585 bytes .../512-0-locked.png | Bin 0 -> 5037 bytes .../512-1-shared.png | Bin 0 -> 9135 bytes .../64-0-locked.png | Bin 0 -> 580 bytes .../64-1-shared.png | Bin 0 -> 656 bytes 59 files changed, 1431 insertions(+), 187 deletions(-) create mode 100644 cmake/modules/GenerateIconsUtils.cmake create mode 100644 src/libsync/vfs/cfapi/shellext/CustomStateProvider.idl create mode 100644 src/libsync/vfs/cfapi/shellext/configvfscfapishellext.h.in create mode 100644 src/libsync/vfs/cfapi/shellext/customstateprovider.cpp create mode 100644 src/libsync/vfs/cfapi/shellext/customstateprovider.h create mode 100644 src/libsync/vfs/cfapi/shellext/customstateprovideripc.cpp create mode 100644 src/libsync/vfs/cfapi/shellext/customstateprovideripc.h create mode 100644 src/libsync/vfs/cfapi/shellext/ipccommon.cpp create mode 100644 src/libsync/vfs/cfapi/shellext/ipccommon.h create mode 100644 theme/cfapishellext_custom_states/0-locked.svg create mode 100644 theme/cfapishellext_custom_states/1-shared.svg create mode 100644 theme/cfapishellext_custom_states/1024-0-locked.png create mode 100644 theme/cfapishellext_custom_states/1024-1-shared.png create mode 100644 theme/cfapishellext_custom_states/128-0-locked.png create mode 100644 theme/cfapishellext_custom_states/128-1-shared.png create mode 100644 theme/cfapishellext_custom_states/24-0-locked.png create mode 100644 theme/cfapishellext_custom_states/24-1-shared.png create mode 100644 theme/cfapishellext_custom_states/256-0-locked.png create mode 100644 theme/cfapishellext_custom_states/256-1-shared.png create mode 100644 theme/cfapishellext_custom_states/32-0-locked.png create mode 100644 theme/cfapishellext_custom_states/32-1-shared.png create mode 100644 theme/cfapishellext_custom_states/40-0-locked.png create mode 100644 theme/cfapishellext_custom_states/40-1-shared.png create mode 100644 theme/cfapishellext_custom_states/48-0-locked.png create mode 100644 theme/cfapishellext_custom_states/48-1-shared.png create mode 100644 theme/cfapishellext_custom_states/512-0-locked.png create mode 100644 theme/cfapishellext_custom_states/512-1-shared.png create mode 100644 theme/cfapishellext_custom_states/64-0-locked.png create mode 100644 theme/cfapishellext_custom_states/64-1-shared.png diff --git a/CMakeLists.txt b/CMakeLists.txt index a8d41aeea..23f292d44 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,10 @@ set( CFAPI_SHELL_EXTENSIONS_LIB_NAME CfApiShellExtensions ) set( CFAPI_SHELLEXT_APPID_REG "{E314A650-DCA4-416E-974E-18EA37C213EA}") set( CFAPI_SHELLEXT_APPID_DISPLAY_NAME "${APPLICATION_NAME} CfApi Shell Extensions" ) +set( CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID "1E62D59A-6EA4-476C-B707-4A32E88ED822" ) +set( CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG "{${CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID}}" ) +set( CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_DISPLAY_NAME "${APPLICATION_NAME} Custom State Handler" ) + set( CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID "6FF9B5B6-389F-444A-9FDD-A286C36EA079" ) set( CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG "{${CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID}}" ) set( CFAPI_SHELLEXT_THUMBNAIL_HANDLER_DISPLAY_NAME "${APPLICATION_NAME} Thumbnail Handler" ) diff --git a/admin/win/msi/RegistryCleanup.vbs.in b/admin/win/msi/RegistryCleanup.vbs.in index 35c3b7651..0eeb1a4ed 100644 --- a/admin/win/msi/RegistryCleanup.vbs.in +++ b/admin/win/msi/RegistryCleanup.vbs.in @@ -53,19 +53,25 @@ End Function Function RegistryCleanupCfApiShellExtensions() Set objRegistry = GetObject(strObjRegistry) - strShellExtThumbnailHandlerAppId = "Software\Classes\AppID\@CFAPI_SHELLEXT_APPID_REG@" + strShellExtAppId = "Software\Classes\AppID\@CFAPI_SHELLEXT_APPID_REG@" + strShellExtThumbnailHandlerClsId = "Software\Classes\CLSID\@CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG@" + strShellExtCustomStateHandlerClsId = "Software\Classes\CLSID\@CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG@" rootKey = HKEY_CURRENT_USER - If objRegistry.EnumKey(rootKey, strShellExtThumbnailHandlerAppId, arrSubKeys) = 0 Then - RegistryDeleteKeyRecursive rootKey, strShellExtThumbnailHandlerAppId + If objRegistry.EnumKey(rootKey, strShellExtAppId, arrSubKeys) = 0 Then + RegistryDeleteKeyRecursive rootKey, strShellExtAppId End If If objRegistry.EnumKey(rootKey, strShellExtThumbnailHandlerClsId, arrSubKeys) = 0 Then RegistryDeleteKeyRecursive rootKey, strShellExtThumbnailHandlerClsId End If + If objRegistry.EnumKey(rootKey, strShellExtCustomStateHandlerClsId, arrSubKeys) = 0 Then + RegistryDeleteKeyRecursive rootKey, strShellExtCustomStateHandlerClsId + End If + End Function Function RegistryCleanup() diff --git a/cmake/modules/ECMAddAppIcon.cmake b/cmake/modules/ECMAddAppIcon.cmake index 61dba6c42..bd27b0590 100644 --- a/cmake/modules/ECMAddAppIcon.cmake +++ b/cmake/modules/ECMAddAppIcon.cmake @@ -102,11 +102,13 @@ include(CMakeParseArguments) function(ecm_add_app_icon appsources) set(options) - set(oneValueArgs OUTFILE_BASENAME ICON_INDEX) + set(oneValueArgs OUTFILE_BASENAME ICON_INDEX DO_NOT_GENERATE_RC_FILE) set(multiValueArgs ICONS SIDEBAR_ICONS RC_DEPENDENCIES) cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) - if (NOT ARG_ICON_INDEX) - set(ARG_ICON_INDEX 1) + if (ARG_DO_NOT_GENERATE_RC_FILE) + set (_do_not_generate_rc_file TRUE) + else() + set (_do_not_generate_rc_file FALSE) endif() if(NOT ARG_ICONS) @@ -211,15 +213,17 @@ function(ecm_add_app_icon appsources) DEPENDS ${deps} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" ) - # this bit's a little hacky to make the dependency stuff work - file(WRITE "${_outfilename}.rc.in" "IDI_ICON${ARG_ICON_INDEX} ICON DISCARDABLE \"${_outfilename}.ico\"\n") - add_custom_command( - OUTPUT "${_outfilename}.rc" - COMMAND ${CMAKE_COMMAND} - ARGS -E copy "${_outfilename}.rc.in" "${_outfilename}.rc" - DEPENDS ${ARG_RC_DEPENDENCIES} "${_outfilename}.ico" - WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" - ) + if (NOT _do_not_generate_rc_file) + # this bit's a little hacky to make the dependency stuff work + file(WRITE "${_outfilename}.rc.in" "IDI_ICON${ARG_ICON_INDEX} ICON DISCARDABLE \"${_outfilename}.ico\"\n") + add_custom_command( + OUTPUT "${_outfilename}.rc" + COMMAND ${CMAKE_COMMAND} + ARGS -E copy "${_outfilename}.rc.in" "${_outfilename}.rc" + DEPENDS ${ARG_RC_DEPENDENCIES} "${_outfilename}.ico" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + ) + endif() endfunction() if (IcoTool_FOUND) diff --git a/cmake/modules/GenerateIconsUtils.cmake b/cmake/modules/GenerateIconsUtils.cmake new file mode 100644 index 000000000..e37b04cfe --- /dev/null +++ b/cmake/modules/GenerateIconsUtils.cmake @@ -0,0 +1,58 @@ +# UPSTREAM our ECMAddAppIcon.cmake then require that version here +# find_package(ECM 1.7.0 REQUIRED NO_MODULE) +# list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) +include(ECMAddAppIcon) + +find_program(SVG_CONVERTER + NAMES inkscape inkscape.exe rsvg-convert + REQUIRED + HINTS "C:\\Program Files\\Inkscape\\bin" "/usr/bin" ENV SVG_CONVERTER_DIR) +# REQUIRED keyword is only supported on CMake 3.18 and above +if (NOT SVG_CONVERTER) + message(FATAL_ERROR "Could not find a suitable svg converter. Set SVG_CONVERTER_DIR to the path of either the inkscape or rsvg-convert executable.") +endif() + +function(generate_sized_png_from_svg icon_path size) + set(options) + set(oneValueArgs OUTPUT_ICON_NAME OUTPUT_ICON_FULL_NAME_WLE OUTPUT_ICON_PATH) + set(multiValueArgs) + + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + get_filename_component(icon_name_dir ${icon_path} DIRECTORY) + get_filename_component(icon_name_wle ${icon_path} NAME_WLE) + + if (ARG_OUTPUT_ICON_NAME) + set(icon_name_wle ${ARG_OUTPUT_ICON_NAME}) + endif () + + if (ARG_OUTPUT_ICON_PATH) + set(icon_name_dir ${ARG_OUTPUT_ICON_PATH}) + endif () + + set(output_icon_full_name_wle "${size}-${icon_name_wle}") + + if (ARG_OUTPUT_ICON_FULL_NAME_WLE) + set(output_icon_full_name_wle ${ARG_OUTPUT_ICON_FULL_NAME_WLE}) + endif () + + if (EXISTS "${icon_name_dir}/${output_icon_full_name_wle}.png") + return() + endif() + + set(icon_output_name "${output_icon_full_name_wle}.png") + message(STATUS "Generate ${icon_output_name}") + execute_process(COMMAND + "${SVG_CONVERTER}" -w ${size} -h ${size} "${icon_path}" -o "${icon_output_name}" + WORKING_DIRECTORY "${icon_name_dir}" + RESULT_VARIABLE + SVG_CONVERTER_SIDEBAR_ERROR + OUTPUT_QUIET + ERROR_QUIET) + + if (SVG_CONVERTER_SIDEBAR_ERROR) + message(FATAL_ERROR + "${SVG_CONVERTER} could not generate icon: ${SVG_CONVERTER_SIDEBAR_ERROR}") + else() + endif() +endfunction() diff --git a/config.h.in b/config.h.in index 9ea359863..1d821d2b4 100644 --- a/config.h.in +++ b/config.h.in @@ -48,6 +48,10 @@ #cmakedefine CFAPI_SHELLEXT_APPID_REG "@CFAPI_SHELLEXT_APPID_REG@" #cmakedefine CFAPI_SHELLEXT_APPID_DISPLAY_NAME "@CFAPI_SHELLEXT_APPID_DISPLAY_NAME@" +#cmakedefine CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID "@CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID@" +#cmakedefine CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG "@CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG@" +#cmakedefine CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_DISPLAY_NAME "@CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_DISPLAY_NAME@" + #cmakedefine CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID "@CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID@" #cmakedefine CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG "@CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG@" #cmakedefine CFAPI_SHELLEXT_THUMBNAIL_HANDLER_DISPLAY_NAME "@CFAPI_SHELLEXT_THUMBNAIL_HANDLER_DISPLAY_NAME@" diff --git a/src/common/shellextensionutils.cpp b/src/common/shellextensionutils.cpp index d6f4b244c..b2b59be80 100644 --- a/src/common/shellextensionutils.cpp +++ b/src/common/shellextensionutils.cpp @@ -29,7 +29,6 @@ namespace Protocol { if (!valid) { qCWarning(lcShellExtensionUtils) << "Invalid shell extensions IPC protocol: " << message.value(QStringLiteral("version")) << " vs " << Version; } - Q_ASSERT(valid); return valid; } } diff --git a/src/common/shellextensionutils.h b/src/common/shellextensionutils.h index ca0d9922d..d0b2e071d 100644 --- a/src/common/shellextensionutils.h +++ b/src/common/shellextensionutils.h @@ -23,11 +23,14 @@ QString serverNameForApplicationName(const QString &applicationName); QString serverNameForApplicationNameDefault(); namespace Protocol { + static constexpr auto CustomStateProviderRequestKey = "customStateProviderRequest"; + static constexpr auto CustomStateDataKey = "customStateData"; + static constexpr auto CustomStateStatesKey = "states"; + static constexpr auto FilePathKey = "filePath"; static constexpr auto ThumbnailProviderRequestKey = "thumbnailProviderRequest"; - static constexpr auto ThumbnailProviderRequestFilePathKey = "filePath"; static constexpr auto ThumbnailProviderRequestFileSizeKey = "fileSize"; static constexpr auto ThumnailProviderDataKey = "thumbnailData"; - static constexpr auto Version = "1.0"; + static constexpr auto Version = "2.0"; QByteArray createJsonMessage(const QVariantMap &message); bool validateProtocolVersion(const QVariantMap &message); diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index ba4a84d4e..d5ede0f01 100644 --- a/src/common/syncjournaldb.cpp +++ b/src/common/syncjournaldb.cpp @@ -49,7 +49,7 @@ Q_LOGGING_CATEGORY(lcDb, "nextcloud.sync.database", QtInfoMsg) #define GET_FILE_RECORD_QUERY \ "SELECT path, inode, modtime, type, md5, fileid, remotePerm, filesize," \ " ignoredChildrenRemote, contentchecksumtype.name || ':' || contentChecksum, e2eMangledName, isE2eEncrypted, " \ - " lock, lockOwnerDisplayName, lockOwnerId, lockType, lockOwnerEditor, lockTime, lockTimeout " \ + " lock, lockOwnerDisplayName, lockOwnerId, lockType, lockOwnerEditor, lockTime, lockTimeout, isShared, lastShareStateFetchedTimestmap " \ " FROM metadata" \ " LEFT JOIN checksumtype as contentchecksumtype ON metadata.contentChecksumTypeId == contentchecksumtype.id" @@ -74,6 +74,8 @@ static void fillFileRecordFromGetQuery(SyncJournalFileRecord &rec, SqlQuery &que rec._lockstate._lockEditorApp = query.stringValue(16); rec._lockstate._lockTime = query.int64Value(17); rec._lockstate._lockTimeout = query.int64Value(18); + rec._isShared = query.intValue(19) > 0; + rec._lastShareStateFetchedTimestmap = query.int64Value(20); } static QByteArray defaultJournalMode(const QString &dbPath) @@ -727,6 +729,8 @@ bool SyncJournalDb::updateMetadataTableStructure() addColumn(QStringLiteral("contentChecksumTypeId"), QStringLiteral("INTEGER")); addColumn(QStringLiteral("e2eMangledName"), QStringLiteral("TEXT")); addColumn(QStringLiteral("isE2eEncrypted"), QStringLiteral("INTEGER")); + addColumn(QStringLiteral("isShared"), QStringLiteral("INTEGER")); + addColumn(QStringLiteral("lastShareStateFetchedTimestmap"), QStringLiteral("INTEGER")); auto uploadInfoColumns = tableColumns("uploadinfo"); if (uploadInfoColumns.isEmpty()) @@ -881,13 +885,17 @@ Result SyncJournalDb::setFileRecord(const SyncJournalFileRecord & } qCInfo(lcDb) << "Updating file record for path:" << record.path() << "inode:" << record._inode - << "modtime:" << record._modtime << "type:" << record._type - << "etag:" << record._etag << "fileId:" << record._fileId << "remotePerm:" << record._remotePerm.toString() + << "modtime:" << record._modtime << "type:" << record._type << "etag:" << record._etag + << "fileId:" << record._fileId << "remotePerm:" << record._remotePerm.toString() << "fileSize:" << record._fileSize << "checksum:" << record._checksumHeader << "e2eMangledName:" << record.e2eMangledName() << "isE2eEncrypted:" << record._isE2eEncrypted - << "lock:" << (record._lockstate._locked ? "true" : "false") << "lock owner type:" << record._lockstate._lockOwnerType - << "lock owner:" << record._lockstate._lockOwnerDisplayName << "lock owner id:" << record._lockstate._lockOwnerId - << "lock editor:" << record._lockstate._lockEditorApp; + << "lock:" << (record._lockstate._locked ? "true" : "false") + << "lock owner type:" << record._lockstate._lockOwnerType + << "lock owner:" << record._lockstate._lockOwnerDisplayName + << "lock owner id:" << record._lockstate._lockOwnerId + << "lock editor:" << record._lockstate._lockEditorApp + << "isShared:" << record._isShared + << "lastShareStateFetchedTimestmap:" << record._lastShareStateFetchedTimestmap; const qint64 phash = getPHash(record._path); if (!checkConnect()) { @@ -913,8 +921,8 @@ Result SyncJournalDb::setFileRecord(const SyncJournalFileRecord & const auto query = _queryManager.get(PreparedSqlQueryManager::SetFileRecordQuery, QByteArrayLiteral("INSERT OR REPLACE INTO metadata " "(phash, pathlen, path, inode, uid, gid, mode, modtime, type, md5, fileid, remotePerm, filesize, ignoredChildrenRemote, " "contentChecksum, contentChecksumTypeId, e2eMangledName, isE2eEncrypted, lock, lockType, lockOwnerDisplayName, lockOwnerId, " - "lockOwnerEditor, lockTime, lockTimeout) " - "VALUES (?1 , ?2, ?3 , ?4 , ?5 , ?6 , ?7, ?8 , ?9 , ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25);"), + "lockOwnerEditor, lockTime, lockTimeout, isShared, lastShareStateFetchedTimestmap) " + "VALUES (?1 , ?2, ?3 , ?4 , ?5 , ?6 , ?7, ?8 , ?9 , ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27);"), _db); if (!query) { return query->error(); @@ -945,6 +953,8 @@ Result SyncJournalDb::setFileRecord(const SyncJournalFileRecord & query->bindValue(23, record._lockstate._lockEditorApp); query->bindValue(24, record._lockstate._lockTime); query->bindValue(25, record._lockstate._lockTimeout); + query->bindValue(26, record._isShared); + query->bindValue(27, record._lastShareStateFetchedTimestmap); if (!query->exec()) { return query->error(); diff --git a/src/common/syncjournalfilerecord.h b/src/common/syncjournalfilerecord.h index 13cf3ef8a..4e011e616 100644 --- a/src/common/syncjournalfilerecord.h +++ b/src/common/syncjournalfilerecord.h @@ -81,6 +81,8 @@ public: QByteArray _e2eMangledName; bool _isE2eEncrypted = false; SyncJournalFileLockInfo _lockstate; + bool _isShared = false; + qint64 _lastShareStateFetchedTimestmap = 0; }; bool OCSYNC_EXPORT diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 0258ff41b..a137916bf 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -353,11 +353,7 @@ if(Qt5Keychain_FOUND) endif() # add executable icon on windows and osx - -# UPSTREAM our ECMAddAppIcon.cmake then require that version here -# find_package(ECM 1.7.0 REQUIRED NO_MODULE) -# list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) -include(ECMAddAppIcon) +include(GenerateIconsUtils) # For historical reasons we can not use the application_shortname # for ownCloud but must rather set it manually. @@ -369,61 +365,6 @@ if(NOT DEFINED APPLICATION_FOLDER_ICON_INDEX) set(APPLICATION_FOLDER_ICON_INDEX 0) endif() -# Generate png icons from svg -find_program(SVG_CONVERTER - NAMES inkscape inkscape.exe rsvg-convert - REQUIRED - HINTS "C:\\Program Files\\Inkscape\\bin" "/usr/bin" ENV SVG_CONVERTER_DIR) -# REQUIRED keyword is only supported on CMake 3.18 and above -if (NOT SVG_CONVERTER) - message(FATAL_ERROR "Could not find a suitable svg converter. Set SVG_CONVERTER_DIR to the path of either the inkscape or rsvg-convert executable.") -endif() - -function(generate_sized_png_from_svg icon_path size) - set(options) - set(oneValueArgs OUTPUT_ICON_NAME OUTPUT_ICON_FULL_NAME_WLE OUTPUT_ICON_PATH) - set(multiValueArgs) - - cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) - - get_filename_component(icon_name_dir ${icon_path} DIRECTORY) - get_filename_component(icon_name_wle ${icon_path} NAME_WLE) - - if (ARG_OUTPUT_ICON_NAME) - set(icon_name_wle ${ARG_OUTPUT_ICON_NAME}) - endif () - - if (ARG_OUTPUT_ICON_PATH) - set(icon_name_dir ${ARG_OUTPUT_ICON_PATH}) - endif () - - set(output_icon_full_name_wle "${size}-${icon_name_wle}") - - if (ARG_OUTPUT_ICON_FULL_NAME_WLE) - set(output_icon_full_name_wle ${ARG_OUTPUT_ICON_FULL_NAME_WLE}) - endif () - - if (EXISTS "${icon_name_dir}/${output_icon_full_name_wle}.png") - return() - endif() - - set(icon_output_name "${output_icon_full_name_wle}.png") - message(STATUS "Generate ${icon_output_name}") - execute_process(COMMAND - "${SVG_CONVERTER}" -w ${size} -h ${size} "${icon_path}" -o "${icon_output_name}" - WORKING_DIRECTORY "${icon_name_dir}" - RESULT_VARIABLE - SVG_CONVERTER_SIDEBAR_ERROR - OUTPUT_QUIET - ERROR_QUIET) - - if (SVG_CONVERTER_SIDEBAR_ERROR) - message(FATAL_ERROR - "${SVG_CONVERTER} could not generate icon: ${SVG_CONVERTER_SIDEBAR_ERROR}") - else() - endif() -endfunction() - set(STATE_ICONS_COLORS colored black white) foreach(state_icons_color ${STATE_ICONS_COLORS}) diff --git a/src/gui/ocsjob.cpp b/src/gui/ocsjob.cpp index ab4038c05..68c680b4d 100644 --- a/src/gui/ocsjob.cpp +++ b/src/gui/ocsjob.cpp @@ -40,7 +40,7 @@ void OcsJob::setVerb(const QByteArray &verb) void OcsJob::addParam(const QString &name, const QString &value) { - _params.append(qMakePair(name, value)); + _params.insert(name, value); } void OcsJob::addPassStatusCode(int code) @@ -58,16 +58,21 @@ void OcsJob::addRawHeader(const QByteArray &headerName, const QByteArray &value) _request.setRawHeader(headerName, value); } +QString OcsJob::getParamValue(const QString &key) const +{ + return _params.value(key); +} + static QUrlQuery percentEncodeQueryItems( - const QList> &items) + const QHash &items) { QUrlQuery result; // Note: QUrlQuery::setQueryItems() does not fully percent encode // the query items, see #5042 - foreach (const auto &item, items) { + for (auto it = std::cbegin(items); it != std::cend(items); ++it) { result.addQueryItem( - QUrl::toPercentEncoding(item.first), - QUrl::toPercentEncoding(item.second)); + QUrl::toPercentEncoding(it.key()), + QUrl::toPercentEncoding(it.value())); } return result; } @@ -85,13 +90,13 @@ void OcsJob::start() } else if (_verb == "POST" || _verb == "PUT") { // Url encode the _postParams and put them in a buffer. QByteArray postData; - Q_FOREACH (auto tmp, _params) { + for (auto it = std::cbegin(_params); it != std::cend(_params); ++it) { if (!postData.isEmpty()) { postData.append("&"); } - postData.append(QUrl::toPercentEncoding(tmp.first)); + postData.append(QUrl::toPercentEncoding(it.key())); postData.append("="); - postData.append(QUrl::toPercentEncoding(tmp.second)); + postData.append(QUrl::toPercentEncoding(it.value())); } buffer->setData(postData); } diff --git a/src/gui/ocsjob.h b/src/gui/ocsjob.h index e3b8b475d..6973d7ce0 100644 --- a/src/gui/ocsjob.h +++ b/src/gui/ocsjob.h @@ -19,8 +19,7 @@ #include "abstractnetworkjob.h" #include -#include -#include +#include #include #define OCS_SUCCESS_STATUS_CODE 100 @@ -110,6 +109,8 @@ public: */ void addRawHeader(const QByteArray &headerName, const QByteArray &value); + [[nodiscard]] QString getParamValue(const QString &key) const; + protected slots: @@ -149,7 +150,7 @@ private slots: private: QByteArray _verb; - QList> _params; + QHash _params; QVector _passStatusCodes; QNetworkRequest _request; }; diff --git a/src/gui/ocssharejob.cpp b/src/gui/ocssharejob.cpp index 900074dbc..13c494c4e 100644 --- a/src/gui/ocssharejob.cpp +++ b/src/gui/ocssharejob.cpp @@ -24,16 +24,21 @@ namespace OCC { OcsShareJob::OcsShareJob(AccountPtr account) : OcsJob(account) { - setPath("ocs/v2.php/apps/files_sharing/api/v1/shares"); + setPath(_pathForSharesRequest); connect(this, &OcsJob::jobFinished, this, &OcsShareJob::jobDone); } -void OcsShareJob::getShares(const QString &path) +void OcsShareJob::getShares(const QString &path, const QMap ¶ms) { setVerb("GET"); addParam(QString::fromLatin1("path"), path); addParam(QString::fromLatin1("reshares"), QString("true")); + + for (auto it = std::cbegin(params); it != std::cend(params); ++it) { + addParam(it.key(), it.value()); + } + addPassStatusCode(404); start(); @@ -181,4 +186,6 @@ void OcsShareJob::jobDone(QJsonDocument reply) { emit shareJobFinished(reply, _value); } + +QString const OcsShareJob::_pathForSharesRequest = QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/shares"); } diff --git a/src/gui/ocssharejob.h b/src/gui/ocssharejob.h index 064706b5c..61d8c1d26 100644 --- a/src/gui/ocssharejob.h +++ b/src/gui/ocssharejob.h @@ -46,7 +46,7 @@ public: * * @param path Path to request shares for (default all shares) */ - void getShares(const QString &path = ""); + void getShares(const QString &path = "", const QMap ¶ms = {}); /** * Delete the current Share @@ -131,6 +131,8 @@ public: */ void getSharedWithMe(); + static const QString _pathForSharesRequest; + signals: /** * Result of the OCS request diff --git a/src/gui/shellextensionsserver.cpp b/src/gui/shellextensionsserver.cpp index fa97a1b62..2b64c2b99 100644 --- a/src/gui/shellextensionsserver.cpp +++ b/src/gui/shellextensionsserver.cpp @@ -16,29 +16,58 @@ #include "account.h" #include "accountstate.h" #include "common/shellextensionutils.h" +#include #include "folder.h" #include "folderman.h" +#include "ocssharejob.h" #include +#include #include +#include #include +namespace { +constexpr auto isSharedInvalidationInterval = 2 * 60 * 1000; // 2 minutes, so we don't make fetch sharees requests too often +constexpr auto folderAliasPropertyKey = "folderAlias"; +} + namespace OCC { +Q_LOGGING_CATEGORY(lcShellExtServer, "nextcloud.gui.shellextensions.server", QtInfoMsg) + ShellExtensionsServer::ShellExtensionsServer(QObject *parent) : QObject(parent) { + _isSharedInvalidationInterval = isSharedInvalidationInterval; _localServer.listen(VfsShellExtensions::serverNameForApplicationNameDefault()); connect(&_localServer, &QLocalServer::newConnection, this, &ShellExtensionsServer::slotNewConnection); } ShellExtensionsServer::~ShellExtensionsServer() { + for (const auto &connection : _customStateSocketConnections) { + if (connection) { + QObject::disconnect(connection); + } + } + _customStateSocketConnections.clear(); + if (!_localServer.isListening()) { return; } _localServer.close(); } +QString ShellExtensionsServer::getFetchThumbnailPath() +{ + return QStringLiteral("/index.php/core/preview"); +} + +void ShellExtensionsServer::setIsSharedInvalidationInterval(qint64 interval) +{ + _isSharedInvalidationInterval = interval; +} + void ShellExtensionsServer::sendJsonMessageWithVersion(QLocalSocket *socket, const QVariantMap &message) { socket->write(VfsShellExtensions::Protocol::createJsonMessage(message)); @@ -60,6 +89,96 @@ void ShellExtensionsServer::closeSession(QLocalSocket *socket) socket->disconnectFromServer(); } +void ShellExtensionsServer::processCustomStateRequest(QLocalSocket *socket, const CustomStateRequestInfo &customStateRequestInfo) +{ + if (!customStateRequestInfo.isValid()) { + sendEmptyDataAndCloseSession(socket); + return; + } + + const auto folder = FolderMan::instance()->folder(customStateRequestInfo.folderAlias); + + if (!folder) { + sendEmptyDataAndCloseSession(socket); + return; + } + const auto filePathRelative = QString(customStateRequestInfo.path).remove(folder->path()); + + SyncJournalFileRecord record; + if (!folder->journalDb()->getFileRecord(filePathRelative, &record) || !record.isValid() || record.path().isEmpty()) { + qCWarning(lcShellExtServer) << "Record not found in SyncJournal for: " << filePathRelative; + sendEmptyDataAndCloseSession(socket); + return; + } + + const auto composeMessageReplyFromRecord = [](const SyncJournalFileRecord &record) { + QVariantList states; + if (record._lockstate._locked) { + states.push_back(QString(CUSTOM_STATE_ICON_LOCKED_INDEX).toInt() - QString(CUSTOM_STATE_ICON_INDEX_OFFSET).toInt()); + } + if (record._isShared) { + states.push_back(QString(CUSTOM_STATE_ICON_SHARED_INDEX).toInt() - QString(CUSTOM_STATE_ICON_INDEX_OFFSET).toInt()); + } + return QVariantMap{{VfsShellExtensions::Protocol::CustomStateDataKey, + QVariantMap{{VfsShellExtensions::Protocol::CustomStateStatesKey, states}}}}; + }; + + if (QDateTime::currentMSecsSinceEpoch() - record._lastShareStateFetchedTimestmap < _isSharedInvalidationInterval) { + qCInfo(lcShellExtServer) << record.path() << " record._lastShareStateFetchedTimestmap has less than " << _isSharedInvalidationInterval << " ms difference with QDateTime::currentMSecsSinceEpoch(). Returning data from SyncJournal."; + sendJsonMessageWithVersion(socket, composeMessageReplyFromRecord(record)); + closeSession(socket); + return; + } + + const auto job = new OcsShareJob(folder->accountState()->account()); + job->setProperty(folderAliasPropertyKey, customStateRequestInfo.folderAlias); + connect(job, &OcsShareJob::shareJobFinished, this, &ShellExtensionsServer::slotSharesFetched); + connect(job, &OcsJob::ocsError, this, &ShellExtensionsServer::slotSharesFetchError); + + { + _customStateSocketConnections.insert(socket->socketDescriptor(), QObject::connect(this, &ShellExtensionsServer::fetchSharesJobFinished, [this, socket, filePathRelative, composeMessageReplyFromRecord](const QString &folderAlias) { + { + const auto connection = _customStateSocketConnections[socket->socketDescriptor()]; + if (connection) { + QObject::disconnect(connection); + } + _customStateSocketConnections.remove(socket->socketDescriptor()); + } + + const auto folder = FolderMan::instance()->folder(folderAlias); + SyncJournalFileRecord record; + if (!folder || !folder->journalDb()->getFileRecord(filePathRelative, &record) || !record.isValid()) { + qCWarning(lcShellExtServer) << "Record not found in SyncJournal for: " << filePathRelative; + sendEmptyDataAndCloseSession(socket); + return; + } + + qCInfo(lcShellExtServer) << "Sending reply from OcsShareJob for socket: " << socket->socketDescriptor() << " and record: " << record.path(); + sendJsonMessageWithVersion(socket, composeMessageReplyFromRecord(record)); + closeSession(socket); + })); + } + + const auto sharesPath = [&record, folder, &filePathRelative]() { + const auto filePathRelativeRemote = QDir(folder->remotePath()).filePath(filePathRelative); + // either get parent's path, or, return '/' if we are in the root folder + auto recordPathSplit = filePathRelativeRemote.split(QLatin1Char('/'), Qt::SkipEmptyParts); + if (recordPathSplit.size() > 1) { + recordPathSplit.removeLast(); + return recordPathSplit.join(QLatin1Char('/')); + } + return QStringLiteral("/"); + }(); + + if (!_runningFetchShareJobsForPaths.contains(sharesPath)) { + _runningFetchShareJobsForPaths.push_back(sharesPath); + qCInfo(lcShellExtServer) << "Started OcsShareJob for path: " << sharesPath; + job->getShares(sharesPath, {{QStringLiteral("subfiles"), QStringLiteral("true")}}); + } else { + qCInfo(lcShellExtServer) << "OcsShareJob is already running for path: " << sharesPath; + } +} + void ShellExtensionsServer::processThumbnailRequest(QLocalSocket *socket, const ThumbnailRequestInfo &thumbnailRequestInfo) { if (!thumbnailRequestInfo.isValid()) { @@ -87,7 +206,7 @@ void ShellExtensionsServer::processThumbnailRequest(QLocalSocket *socket, const queryItems.addQueryItem(QStringLiteral("fileId"), record._fileId); queryItems.addQueryItem(QStringLiteral("x"), QString::number(thumbnailRequestInfo.size.width())); queryItems.addQueryItem(QStringLiteral("y"), QString::number(thumbnailRequestInfo.size.height())); - const QUrl jobUrl = Utility::concatUrlPath(folder->accountState()->account()->url(), QStringLiteral("/index.php/core/preview"), queryItems); + const QUrl jobUrl = Utility::concatUrlPath(folder->accountState()->account()->url(), getFetchThumbnailPath(), queryItems); const auto job = new SimpleNetworkJob(folder->accountState()->account()); job->startRequest(QByteArrayLiteral("GET"), jobUrl); connect(job, &SimpleNetworkJob::finishedSignal, this, [socket, this](QNetworkReply *reply) { @@ -121,8 +240,155 @@ void ShellExtensionsServer::slotNewConnection() return; } + if (message.contains(VfsShellExtensions::Protocol::ThumbnailProviderRequestKey)) { + parseThumbnailRequest(socket, message); + return; + } else if (message.contains(VfsShellExtensions::Protocol::CustomStateProviderRequestKey)) { + parseCustomStateRequest(socket, message); + return; + } + qCWarning(lcShellExtServer) << "Invalid message received from shell extension: " << message; + sendEmptyDataAndCloseSession(socket); + return; +} + +void ShellExtensionsServer::slotSharesFetched(const QJsonDocument &reply) +{ + const auto job = qobject_cast(sender()); + + Q_ASSERT(job); + if (!job) { + qCWarning(lcShellExtServer) << "ShellExtensionsServer::slotSharesFetched is not called by OcsShareJob's signal!"; + return; + } + + const auto sharesPath = job->getParamValue(QStringLiteral("path")); + + _runningFetchShareJobsForPaths.removeAll(sharesPath); + + const auto folderAlias = job->property(folderAliasPropertyKey).toString(); + + Q_ASSERT(!folderAlias.isEmpty()); + if (folderAlias.isEmpty()) { + qCWarning(lcShellExtServer) << "No 'folderAlias' set for OcsShareJob's instance!"; + return; + } + + const auto folder = FolderMan::instance()->folder(folderAlias); + + Q_ASSERT(folder); + if (!folder) { + qCWarning(lcShellExtServer) << "folder not found for folderAlias: " << folderAlias; + return; + } + + const auto timeStamp = QDateTime::currentMSecsSinceEpoch(); + QStringList recortPathsToResetIsSharedFlag; + const QByteArray pathOfSharesToResetIsSharedFlag = sharesPath == QStringLiteral("/") ? QByteArrayLiteral("") : sharesPath.toUtf8(); + if (folder->journalDb()->listFilesInPath(pathOfSharesToResetIsSharedFlag, [&](const SyncJournalFileRecord &rec) { + recortPathsToResetIsSharedFlag.push_back(rec.path()); + })) { + for (const auto &recordPath : recortPathsToResetIsSharedFlag) { + SyncJournalFileRecord record; + if (!folder->journalDb()->getFileRecord(recordPath, &record) || !record.isValid()) { + continue; + } + record._isShared = false; + record._lastShareStateFetchedTimestmap = timeStamp; + if (!folder->journalDb()->setFileRecord(record)) { + qCWarning(lcShellExtServer) << "Could not set file record for path: " << record._path; + } + } + } + + const auto sharesFetched = reply.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toArray(); + + for (const auto &share : sharesFetched) { + const auto shareData = share.toObject(); + + const auto sharePath = [&shareData, folder]() { + const auto sharePathRemote = shareData.value(QStringLiteral("path")).toString(); + + const auto folderPath = folder->remotePath(); + if (folderPath != QLatin1Char('/') && sharePathRemote.startsWith(folderPath)) { + // shares are ruturned with absolute remote path, so, if we have our remote root set to subfolder, we need to adjust share's remote path to relative local path + const auto sharePathLocalRelative = sharePathRemote.midRef(folder->remotePathTrailingSlash().length()); + return sharePathLocalRelative.toString(); + } + return sharePathRemote.size() > 1 && sharePathRemote.startsWith(QLatin1Char('/')) + ? QString(sharePathRemote).remove(0, 1) + : sharePathRemote; + }(); + + SyncJournalFileRecord record; + if (!folder || !folder->journalDb()->getFileRecord(sharePath, &record) || !record.isValid()) { + continue; + } + record._isShared = true; + record._lastShareStateFetchedTimestmap = timeStamp; + + if (!folder->journalDb()->setFileRecord(record)) { + qCWarning(lcShellExtServer) << "Could not set file record for path: " << record._path; + } + } + + qCInfo(lcShellExtServer) << "Succeeded OcsShareJob for path: " << sharesPath; + emit fetchSharesJobFinished(folderAlias); +} + +void ShellExtensionsServer::slotSharesFetchError(int statusCode, const QString &message) +{ + const auto job = qobject_cast(sender()); + + Q_ASSERT(job); + if (!job) { + qCWarning(lcShellExtServer) << "ShellExtensionsServer::slotSharesFetched is not called by OcsShareJob's signal!"; + return; + } + + const auto sharesPath = job->getParamValue(QStringLiteral("path")); + + _runningFetchShareJobsForPaths.removeAll(sharesPath); + + emit fetchSharesJobFinished(sharesPath); + qCWarning(lcShellExtServer) << "Failed OcsShareJob for path: " << sharesPath; +} + +void ShellExtensionsServer::parseCustomStateRequest(QLocalSocket *socket, const QVariantMap &message) +{ + const auto customStateRequestMessage = message.value(VfsShellExtensions::Protocol::CustomStateProviderRequestKey).toMap(); + const auto itemFilePath = QDir::fromNativeSeparators(customStateRequestMessage.value(VfsShellExtensions::Protocol::FilePathKey).toString()); + + if (itemFilePath.isEmpty()) { + sendEmptyDataAndCloseSession(socket); + return; + } + + QString foundFolderAlias; + for (const auto folder : FolderMan::instance()->map()) { + if (itemFilePath.startsWith(folder->path())) { + foundFolderAlias = folder->alias(); + break; + } + } + + if (foundFolderAlias.isEmpty()) { + sendEmptyDataAndCloseSession(socket); + return; + } + + const auto customStateRequestInfo = CustomStateRequestInfo { + itemFilePath, + foundFolderAlias + }; + + processCustomStateRequest(socket, customStateRequestInfo); +} + +void ShellExtensionsServer::parseThumbnailRequest(QLocalSocket *socket, const QVariantMap &message) +{ const auto thumbnailRequestMessage = message.value(VfsShellExtensions::Protocol::ThumbnailProviderRequestKey).toMap(); - const auto thumbnailFilePath = QDir::fromNativeSeparators(thumbnailRequestMessage.value(VfsShellExtensions::Protocol::ThumbnailProviderRequestFilePathKey).toString()); + const auto thumbnailFilePath = QDir::fromNativeSeparators(thumbnailRequestMessage.value(VfsShellExtensions::Protocol::FilePathKey).toString()); const auto thumbnailFileSize = thumbnailRequestMessage.value(VfsShellExtensions::Protocol::ThumbnailProviderRequestFileSizeKey).toMap(); if (thumbnailFilePath.isEmpty() || thumbnailFileSize.isEmpty()) { diff --git a/src/gui/shellextensionsserver.h b/src/gui/shellextensionsserver.h index 50df905ac..491d4cfe0 100644 --- a/src/gui/shellextensionsserver.h +++ b/src/gui/shellextensionsserver.h @@ -16,8 +16,11 @@ #include #include +#include #include +#include +class QJsonDocument; class QLocalSocket; namespace OCC { @@ -32,21 +35,45 @@ class ShellExtensionsServer : public QObject [[nodiscard]] bool isValid() const { return !path.isEmpty() && !size.isEmpty() && !folderAlias.isEmpty(); } }; + struct CustomStateRequestInfo + { + QString path; + QString folderAlias; + + bool isValid() const { return !path.isEmpty() && !folderAlias.isEmpty(); } + }; + Q_OBJECT public: ShellExtensionsServer(QObject *parent = nullptr); ~ShellExtensionsServer() override; + static QString getFetchThumbnailPath(); + + void setIsSharedInvalidationInterval(qint64 interval); + +signals: + void fetchSharesJobFinished(const QString &folderAlias); + private: void sendJsonMessageWithVersion(QLocalSocket *socket, const QVariantMap &message); void sendEmptyDataAndCloseSession(QLocalSocket *socket); void closeSession(QLocalSocket *socket); + void processCustomStateRequest(QLocalSocket *socket, const CustomStateRequestInfo &customStateRequestInfo); void processThumbnailRequest(QLocalSocket *socket, const ThumbnailRequestInfo &thumbnailRequestInfo); + void parseCustomStateRequest(QLocalSocket *socket, const QVariantMap &message); + void parseThumbnailRequest(QLocalSocket *socket, const QVariantMap &message); + private slots: void slotNewConnection(); + void slotSharesFetched(const QJsonDocument &reply); + void slotSharesFetchError(int statusCode, const QString &message); private: QLocalServer _localServer; + QStringList _runningFetchShareJobsForPaths; + QMap _customStateSocketConnections; + qint64 _isSharedInvalidationInterval = 0; }; } // namespace OCC diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 9b14bb63e..4de3317a2 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -982,6 +982,9 @@ void SocketApi::setFileLock(const QString &localFile, const SyncFileItem::LockSt } shareFolder->accountState()->account()->setLockFileState(fileData.serverRelativePath, shareFolder->journalDb(), lockState); + + shareFolder->journalDb()->schedulePathForRemoteDiscovery(fileData.serverRelativePath); + shareFolder->scheduleThisFolderSoon(); } void SocketApi::command_V2_LIST_ACCOUNTS(const QSharedPointer &job) const diff --git a/src/libsync/bulkpropagatorjob.cpp b/src/libsync/bulkpropagatorjob.cpp index 228180711..8afe789e3 100644 --- a/src/libsync/bulkpropagatorjob.cpp +++ b/src/libsync/bulkpropagatorjob.cpp @@ -393,6 +393,8 @@ void BulkPropagatorJob::slotPutFinishedOneFile(const BulkUploadItem &singleFile, singleFile._item->_etag = etag; singleFile._item->_fileId = getHeaderFromJsonReply(fileReply, "fileid"); singleFile._item->_remotePerm = RemotePermissions::fromServerString(getHeaderFromJsonReply(fileReply, "permissions")); + singleFile._item->_isShared = singleFile._item->_remotePerm.hasPermission(RemotePermissions::IsShared); + singleFile._item->_lastShareStateFetchedTimestmap = QDateTime::currentMSecsSinceEpoch(); if (getHeaderFromJsonReply(fileReply, "X-OC-MTime") != "accepted") { // X-OC-MTime is supported since owncloud 5.0. But not when chunking. diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index 95a23c06b..ac6d0e8f8 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -475,6 +475,8 @@ void ProcessDirectoryJob::processFileAnalyzeRemoteInfo( item->_checksumHeader = serverEntry.checksumHeader; item->_fileId = serverEntry.fileId; item->_remotePerm = serverEntry.remotePerm; + item->_isShared = serverEntry.remotePerm.hasPermission(RemotePermissions::IsShared); + item->_lastShareStateFetchedTimestmap = QDateTime::currentMSecsSinceEpoch(); item->_type = serverEntry.isDirectory ? ItemTypeDirectory : ItemTypeFile; item->_etag = serverEntry.etag; item->_directDownloadUrl = serverEntry.directDownloadUrl; @@ -633,6 +635,8 @@ void ProcessDirectoryJob::processFileAnalyzeRemoteInfo( item->_direction = SyncFileItem::Up; item->_fileId = serverEntry.fileId; item->_remotePerm = serverEntry.remotePerm; + item->_isShared = serverEntry.remotePerm.hasPermission(RemotePermissions::IsShared); + item->_lastShareStateFetchedTimestmap = QDateTime::currentMSecsSinceEpoch(); item->_etag = serverEntry.etag; item->_type = serverEntry.isDirectory ? CSyncEnums::ItemTypeDirectory : CSyncEnums::ItemTypeFile; @@ -919,6 +923,8 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo( item->_remotePerm = base.isValid() ? base._remotePerm : RemotePermissions{}; item->_etag = base.isValid() ? base._etag : QByteArray{}; item->_type = base.isValid() ? base._type : localEntry.type; + item->_isShared = base.isValid() ? base._isShared : false; + item->_lastShareStateFetchedTimestmap = base.isValid() ? base._lastShareStateFetchedTimestmap : 0; }; if (!localEntry.isValid()) { @@ -1326,6 +1332,8 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo( item->_direction = SyncFileItem::Up; item->_fileId = base._fileId; item->_remotePerm = base._remotePerm; + item->_isShared = base._isShared; + item->_lastShareStateFetchedTimestmap = base._lastShareStateFetchedTimestmap; item->_etag = base._etag; item->_type = base._type; @@ -1451,6 +1459,8 @@ void ProcessDirectoryJob::processFileConflict(const SyncFileItemPtr &item, Proce rec._type = item->_type; rec._fileSize = serverEntry.size; rec._remotePerm = serverEntry.remotePerm; + rec._isShared = serverEntry.remotePerm.hasPermission(RemotePermissions::IsShared); + rec._lastShareStateFetchedTimestmap = QDateTime::currentMSecsSinceEpoch(); rec._checksumHeader = serverEntry.checksumHeader; const auto result = _discoveryData->_statedb->setFileRecord(rec); if (!result) { diff --git a/src/libsync/propagateremotemkdir.cpp b/src/libsync/propagateremotemkdir.cpp index f95fbca01..5c4ea1e48 100644 --- a/src/libsync/propagateremotemkdir.cpp +++ b/src/libsync/propagateremotemkdir.cpp @@ -144,6 +144,8 @@ void PropagateRemoteMkdir::finalizeMkColJob(QNetworkReply::NetworkError err, con connect(propfindJob, &PropfindJob::result, this, [this, jobPath](const QVariantMap &result){ propagator()->_activeJobList.removeOne(this); _item->_remotePerm = RemotePermissions::fromServerString(result.value(QStringLiteral("permissions")).toString()); + _item->_isShared = _item->_remotePerm.hasPermission(RemotePermissions::IsShared); + _item->_lastShareStateFetchedTimestmap = QDateTime::currentMSecsSinceEpoch(); if (!_uploadEncryptedHelper && !_item->_isEncrypted) { success(); diff --git a/src/libsync/syncfileitem.cpp b/src/libsync/syncfileitem.cpp index 07202d41f..97971d29f 100644 --- a/src/libsync/syncfileitem.cpp +++ b/src/libsync/syncfileitem.cpp @@ -41,6 +41,8 @@ SyncJournalFileRecord SyncFileItem::toSyncJournalFileRecordWithInode(const QStri rec._fileId = _fileId; rec._fileSize = _size; rec._remotePerm = _remotePerm; + rec._isShared = _isShared; + rec._lastShareStateFetchedTimestmap = _lastShareStateFetchedTimestmap; rec._serverHasIgnoredFiles = _serverHasIgnoredFiles; rec._checksumHeader = _checksumHeader; rec._e2eMangledName = _encryptedFileName.toUtf8(); @@ -89,6 +91,8 @@ SyncFileItemPtr SyncFileItem::fromSyncJournalFileRecord(const SyncJournalFileRec item->_lockEditorApp = rec._lockstate._lockEditorApp; item->_lockTime = rec._lockstate._lockTime; item->_lockTimeout = rec._lockstate._lockTimeout; + item->_isShared = rec._isShared; + item->_lastShareStateFetchedTimestmap = rec._lastShareStateFetchedTimestmap; return item; } diff --git a/src/libsync/syncfileitem.h b/src/libsync/syncfileitem.h index bda583e76..1e134eee1 100644 --- a/src/libsync/syncfileitem.h +++ b/src/libsync/syncfileitem.h @@ -308,6 +308,9 @@ public: QString _lockEditorApp; qint64 _lockTime = 0; qint64 _lockTimeout = 0; + + bool _isShared = false; + time_t _lastShareStateFetchedTimestmap = 0; }; inline bool operator<(const SyncFileItemPtr &item1, const SyncFileItemPtr &item2) diff --git a/src/libsync/vfs/cfapi/cfapiwrapper.cpp b/src/libsync/vfs/cfapi/cfapiwrapper.cpp index 4975ca881..e98d02792 100644 --- a/src/libsync/vfs/cfapi/cfapiwrapper.cpp +++ b/src/libsync/vfs/cfapi/cfapiwrapper.cpp @@ -442,7 +442,8 @@ bool createSyncRootRegistryKeys(const QString &providerName, const QString &fold { providerSyncRootIdRegistryKey, QStringLiteral("Flags"), REG_DWORD, flags }, { providerSyncRootIdRegistryKey, QStringLiteral("DisplayNameResource"), REG_EXPAND_SZ, displayName }, { providerSyncRootIdRegistryKey, QStringLiteral("IconResource"), REG_EXPAND_SZ, QString(QDir::toNativeSeparators(qApp->applicationFilePath()) + QStringLiteral(",0")) }, - { providerSyncRootIdUserSyncRootsRegistryKey, windowsSid, REG_SZ, syncRootPath}, + { providerSyncRootIdUserSyncRootsRegistryKey, windowsSid, REG_SZ, syncRootPath}, + { providerSyncRootIdRegistryKey, QStringLiteral("CustomStateHandler"), REG_SZ, CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG}, { providerSyncRootIdRegistryKey, QStringLiteral("ThumbnailProvider"), REG_SZ, CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG}, { providerSyncRootIdRegistryKey, QStringLiteral("NamespaceCLSID"), REG_SZ, QString(navigationPaneClsid)} }; @@ -550,6 +551,7 @@ void unregisterSyncRootShellExtensions(const QString &providerName, const QStrin const QString providerSyncRootIdRegistryKey = syncRootManagerRegKey + QStringLiteral("\\") + syncRootId; OCC::Utility::registryDeleteKeyValue(HKEY_LOCAL_MACHINE, providerSyncRootIdRegistryKey, QStringLiteral("ThumbnailProvider")); + OCC::Utility::registryDeleteKeyValue(HKEY_LOCAL_MACHINE, providerSyncRootIdRegistryKey, QStringLiteral("CustomStateHandler")); qCInfo(lcCfApiWrapper) << "Successfully unregistered SyncRoot Shell Extensions!"; } diff --git a/src/libsync/vfs/cfapi/shellext/CMakeLists.txt b/src/libsync/vfs/cfapi/shellext/CMakeLists.txt index 966ae60e0..067289ee4 100644 --- a/src/libsync/vfs/cfapi/shellext/CMakeLists.txt +++ b/src/libsync/vfs/cfapi/shellext/CMakeLists.txt @@ -1,18 +1,197 @@ +include(GenerateIconsUtils) + +# generate custom states icons +set(theme_dir ${CMAKE_SOURCE_DIR}/theme) +set(custom_state_icons_path "${theme_dir}/cfapishellext_custom_states") +set(CUSTOM_STATE_ICON_LOCKED_PATH "${custom_state_icons_path}/0-locked.svg") +set(CUSTOM_STATE_ICON_SHARED_PATH "${custom_state_icons_path}/1-shared.svg") + +foreach(size IN ITEMS 24;32;40;48;64;128;256;512;1024) + get_filename_component(output_icon_name_custom_state_locked ${CUSTOM_STATE_ICON_LOCKED_PATH} NAME_WLE) + generate_sized_png_from_svg(${CUSTOM_STATE_ICON_LOCKED_PATH} ${size} OUTPUT_ICON_NAME ${output_icon_name_custom_state_locked} OUTPUT_ICON_PATH "${custom_state_icons_path}/") +endforeach() + +foreach(size IN ITEMS 24;32;40;48;64;128;256;512;1024) + get_filename_component(output_icon_name_custom_state_shared ${CUSTOM_STATE_ICON_SHARED_PATH} NAME_WLE) + generate_sized_png_from_svg(${CUSTOM_STATE_ICON_SHARED_PATH} ${size} OUTPUT_ICON_NAME ${output_icon_name_custom_state_shared} OUTPUT_ICON_PATH "${custom_state_icons_path}/") +endforeach() + +# offset is used for referencing icon within the binary's resources (indexing start with 0, while IDI_ICON{i} 'i' starts with 1) +if(NOT DEFINED CUSTOM_STATE_ICON_INDEX_OFFSET) + set(CUSTOM_STATE_ICON_INDEX_OFFSET 1) +endif() + +# indeces used for referencing icon within the binary's resources and .rc file's IDI_ICON{i} entries 'i' +if(NOT DEFINED CUSTOM_STATE_ICON_LOCKED_INDEX) + set(CUSTOM_STATE_ICON_LOCKED_INDEX 1) +endif() +if(NOT DEFINED CUSTOM_STATE_ICON_SHARED_INDEX) + set(CUSTOM_STATE_ICON_SHARED_INDEX 2) +endif() + +file(GLOB_RECURSE CUSTOM_STATE_ICONS_LOCKED "${custom_state_icons_path}/*-locked.png*") +get_filename_component(CUSTOM_STATE_ICON_LOCKED_NAME ${CUSTOM_STATE_ICON_LOCKED_PATH} NAME_WLE) +ecm_add_app_icon(CUSTOM_STATE_ICON_LOCKED_OUT ICONS "${CUSTOM_STATE_ICONS_LOCKED}" OUTFILE_BASENAME "${CUSTOM_STATE_ICON_LOCKED_NAME}" DO_NOT_GENERATE_RC_FILE TRUE) + +file(GLOB_RECURSE CUSTOM_STATE_ICONS_SHARED "${custom_state_icons_path}/*-shared.png*") +get_filename_component(CUSTOM_STATE_ICON_SHARED_NAME ${CUSTOM_STATE_ICON_SHARED_PATH} NAME_WLE) +ecm_add_app_icon(CUSTOM_STATE_ICON_SHARED_OUT ICONS "${CUSTOM_STATE_ICONS_SHARED}" OUTFILE_BASENAME "${CUSTOM_STATE_ICON_SHARED_NAME}" DO_NOT_GENERATE_RC_FILE TRUE) + +file(REMOVE "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc.in") + +file(APPEND "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc.in" "IDI_ICON${CUSTOM_STATE_ICON_LOCKED_INDEX} ICON DISCARDABLE \"${CMAKE_CURRENT_BINARY_DIR}/${CUSTOM_STATE_ICON_LOCKED_NAME}.ico\"\n") +file(APPEND "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc.in" "IDI_ICON${CUSTOM_STATE_ICON_SHARED_INDEX} ICON DISCARDABLE \"${CMAKE_CURRENT_BINARY_DIR}/${CUSTOM_STATE_ICON_SHARED_NAME}.ico\"\n") + +add_custom_command( + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc" + COMMAND ${CMAKE_COMMAND} + ARGS -E copy "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc.in" "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc" + DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/${CUSTOM_STATE_ICON_LOCKED_NAME}.ico" "${CMAKE_CURRENT_BINARY_DIR}/${CUSTOM_STATE_ICON_SHARED_NAME}.ico" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" +) +message("CUSTOM_STATE_ICON_LOCKED_OUT: ${CUSTOM_STATE_ICON_LOCKED_OUT}") +message("CUSTOM_STATE_ICON_SHARED_OUT: ${CUSTOM_STATE_ICON_SHARED_OUT}") +# + +# Windows SDK command-line tools require native paths +file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}" MidleFileFolder) +set(GeneratedFilesPath "${CMAKE_CURRENT_BINARY_DIR}\\Generated") +set(MidlOutputPathHeader "${GeneratedFilesPath}\\CustomStateProvider.g.h") +set(MidlOutputPathTlb "${GeneratedFilesPath}\\CustomStateProvider.tlb") +set(MidlOutputPathWinmd "${GeneratedFilesPath}\\CustomStateProvider.winmd") + +add_custom_target(CustomStateProviderImpl + DEPENDS ${MidlOutputPathHeader} +) + +if(NOT DEFINED ENV{WindowsSdkDir}) + message("Getting WindowsSdkDir from Registry") + get_filename_component(WindowsSdkDir "[HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows Kits\\Installed Roots;KitsRoot10]" ABSOLUTE) +else() + set(WindowsSdkDir $ENV{WindowsSdkDir}) + message("Setting WindowsSdkDir from ENV{WindowsSdkDir") +endif() + +# we need cmake path to work with subfolders +file(TO_CMAKE_PATH "${WindowsSdkDir}" WindowsSdkDir) + +MACRO(SUBDIRLIST result curdir) + FILE(GLOB children RELATIVE ${curdir} ${curdir}/*) + SET(dirlist "") + FOREACH(child ${children}) + IF(IS_DIRECTORY ${curdir}/${child}) + LIST(APPEND dirlist ${child}) + ENDIF() + ENDFOREACH() + SET(${result} ${dirlist}) +ENDMACRO() + +SUBDIRLIST(WindowsSdkList "${WindowsSdkDir}/bin") + +# pick only dirs that start with 10.0 +list(FILTER WindowsSdkList INCLUDE REGEX "10.0.") +# sort the list of subdirs and choose the latest +list(SORT WindowsSdkList ORDER ASCENDING) +list(GET WindowsSdkList -1 WindowsSdkLatest) +message("WindowsSdkLatest has been set to: ${WindowsSdkLatest}") + +if(NOT WindowsSdkLatest) + message( FATAL_ERROR "Windows SDK not found") +endif() + +SUBDIRLIST(listFoundationContracts "${WindowsSdkDir}/References/${WindowsSdkLatest}/Windows.Foundation.FoundationContract") +list(FILTER listFoundationContracts INCLUDE REGEX "[0-9]+\.") +list(SORT listFoundationContracts ORDER ASCENDING) +list(GET listFoundationContracts -1 WindowsFoundationContractVersion) +message("WindowsFoundationContractVersion has been set to: ${WindowsFoundationContractVersion}") + +if(NOT WindowsFoundationContractVersion) + message( FATAL_ERROR "Windows Foundation Contract is not found in ${WindowsSdkLatest} SDK.") +endif() + +SUBDIRLIST(listCloudFilesContracts "${WindowsSdkDir}/References/${WindowsSdkLatest}/Windows.Storage.Provider.CloudFilesContract") +list(FILTER listCloudFilesContracts INCLUDE REGEX "[0-9]+\.") +list(SORT listCloudFilesContracts ORDER ASCENDING) +list(GET listCloudFilesContracts -1 WindowsStorageProviderCloudFilesContractVersion) +message("WindowsStorageProviderCloudFilesContractVersion has been set to: ${WindowsStorageProviderCloudFilesContractVersion}") + +if(NOT WindowsStorageProviderCloudFilesContractVersion) + message( FATAL_ERROR "Windows Storage Provider Cloud Files Contract is not found in ${WindowsSdkLatest} SDK.") +endif() + +# we no longer need to work with sub folders, so convert the WindowsSdkDir to native path +file(TO_NATIVE_PATH ${WindowsSdkDir} WindowsSdkDir) +message("WindowsSdkDir has been set to: ${WindowsSdkDir}") +message("WindowsSdkList has been set to: ${WindowsSdkList}") +message("WindowsSdkLatest has been set to: ${WindowsSdkLatest}") + +set(TargetPlatform "x64") +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(TargetPlatform "x64") +elseif(CMAKE_SIZEOF_VOID_P EQUAL 4) + set(TargetPlatform "x86") +endif() + +set(WindowsSDKReferencesPath "${WindowsSdkDir}\\References\\${WindowsSdkLatest}") +set(WindowsSDKBinPathForTools "${WindowsSdkDir}\\bin\\${WindowsSdkLatest}\\${TargetPlatform}") +set(WindowsSDKMetadataDirectory "${WindowsSdkDir}\\UnionMetadata\\${WindowsSdkLatest}") + +IF(NOT EXISTS "${WindowsSDKReferencesPath}" OR NOT IS_DIRECTORY "${WindowsSDKReferencesPath}") + message( FATAL_ERROR "Please install Windows SDK ${WindowsSdkLatest}") +ENDIF() +IF(NOT EXISTS "${WindowsSDKBinPathForTools}" OR NOT IS_DIRECTORY "${WindowsSDKBinPathForTools}") + message( FATAL_ERROR "Please install Windows SDK ${WindowsSdkLatest}") +ENDIF() +IF(NOT EXISTS "${WindowsSDKMetadataDirectory}" OR NOT IS_DIRECTORY "${WindowsSDKMetadataDirectory}") + message( FATAL_ERROR "Please install Windows SDK ${WindowsSdkLatest}") +ENDIF() +set(midlExe "${WindowsSDKBinPathForTools}\\midl.exe") +set(cppWinRtExe "${WindowsSDKBinPathForTools}\\cppwinrt.exe") + +message("cppWinRtExe: ${cppWinRtExe}") +message("midlExe: ${midlExe}") + +# use midl.exe and cppwinrt.exe to generate files for CustomStateProvider (WinRT class) +add_custom_command(OUTPUT ${MidlOutputPathHeader} + COMMAND ${midlExe} /winrt /h nul /tlb ${MidlOutputPathTlb} /winmd ${MidlOutputPathWinmd} /metadata_dir "${WindowsSDKReferencesPath}\\Windows.Foundation.FoundationContract\\${WindowsFoundationContractVersion}" /nomidl /reference "${WindowsSDKReferencesPath}\\Windows.Foundation.FoundationContract\\${WindowsFoundationContractVersion}\\Windows.Foundation.FoundationContract.winmd" /reference "${WindowsSDKReferencesPath}\\Windows.Storage.Provider.CloudFilesContract\\${WindowsStorageProviderCloudFilesContractVersion}\\Windows.Storage.Provider.CloudFilesContract.winmd" /I ${MidleFileFolder} customstateprovider.idl + COMMAND ${cppWinRtExe} -in ${MidlOutputPathWinmd} -comp ${GeneratedFilesPath} -pch pch.h -ref ${WindowsSDKMetadataDirectory} -out ${GeneratedFilesPath} -verbose + COMMENT "Creating generated files from customstateprovider.idl" +) + add_library(CfApiShellExtensions MODULE dllmain.cpp cfapishellintegrationclassfactory.cpp + customstateprovideripc.cpp + ipccommon.cpp thumbnailprovider.cpp thumbnailprovideripc.cpp ${CMAKE_SOURCE_DIR}/src/common/shellextensionutils.cpp + customstateprovider.cpp CfApiShellIntegration.def ) -target_link_libraries(CfApiShellExtensions shlwapi Gdiplus Nextcloud::csync Qt5::Core Qt5::Network) +message("CUSTOM_STATE_ICON_LOCKED_OUT: ${CUSTOM_STATE_ICON_LOCKED_OUT}") +message("CUSTOM_STATE_ICON_SHARED_OUT: ${CUSTOM_STATE_ICON_SHARED_OUT}") +if (CUSTOM_STATE_ICON_LOCKED_OUT AND CUSTOM_STATE_ICON_SHARED_OUT) + message("Adding ${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc...") + target_sources(CfApiShellExtensions PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc") +else() + message(WARNING "Could not add ${CMAKE_CURRENT_BINARY_DIR}/${CFAPI_SHELL_EXTENSIONS_LIB_NAME}.rc to CfApiShellExtensions. Custom states for Windows Virtual Files won't work.") +endif() + + +add_dependencies(CfApiShellExtensions CustomStateProviderImpl) + +target_link_libraries(CfApiShellExtensions shlwapi Gdiplus onecoreuap Nextcloud::csync Qt5::Core Qt5::Network) + +target_include_directories(CfApiShellExtensions PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(CfApiShellExtensions PRIVATE ${GeneratedFilesPath}) target_include_directories(CfApiShellExtensions PRIVATE ${CMAKE_SOURCE_DIR}) +target_compile_features(CfApiShellExtensions PRIVATE cxx_std_17) + set_target_properties(CfApiShellExtensions PROPERTIES LIBRARY_OUTPUT_NAME @@ -29,3 +208,5 @@ install(TARGETS CfApiShellExtensions RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_BINDIR} ) + +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/configvfscfapishellext.h.in ${CMAKE_CURRENT_BINARY_DIR}/configvfscfapishellext.h) diff --git a/src/libsync/vfs/cfapi/shellext/CustomStateProvider.idl b/src/libsync/vfs/cfapi/shellext/CustomStateProvider.idl new file mode 100644 index 000000000..174b828a9 --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/CustomStateProvider.idl @@ -0,0 +1,21 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +namespace CfApiShellExtensions +{ + runtimeclass CustomStateProvider : [default] Windows.Storage.Provider.IStorageProviderItemPropertySource + { + CustomStateProvider(); + } +} diff --git a/src/libsync/vfs/cfapi/shellext/configvfscfapishellext.h.in b/src/libsync/vfs/cfapi/shellext/configvfscfapishellext.h.in new file mode 100644 index 000000000..4f0b193a7 --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/configvfscfapishellext.h.in @@ -0,0 +1,20 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#ifndef CONFIG_VFS_CFAPI_SHELLEXT_H +#define CONFIG_VFS_CFAPI_SHELLEXT_H +#cmakedefine CUSTOM_STATE_ICON_LOCKED_INDEX "@CUSTOM_STATE_ICON_LOCKED_INDEX@" +#cmakedefine CUSTOM_STATE_ICON_SHARED_INDEX "@CUSTOM_STATE_ICON_SHARED_INDEX@" +#cmakedefine CUSTOM_STATE_ICON_INDEX_OFFSET "@CUSTOM_STATE_ICON_INDEX_OFFSET@" +#endif diff --git a/src/libsync/vfs/cfapi/shellext/customstateprovider.cpp b/src/libsync/vfs/cfapi/shellext/customstateprovider.cpp new file mode 100644 index 000000000..8f7a2266f --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/customstateprovider.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "customstateprovider.h" +#include "customstateprovideripc.h" +#include + +extern long dllObjectsCount; + +namespace winrt::CfApiShellExtensions::implementation { + +CustomStateProvider::CustomStateProvider() +{ + InterlockedIncrement(&dllObjectsCount); +} + +CustomStateProvider::~CustomStateProvider() +{ + InterlockedDecrement(&dllObjectsCount); +} + +winrt::Windows::Foundation::Collections::IIterable +CustomStateProvider::GetItemProperties(hstring const &itemPath) +{ + std::vector properties; + + if (_dllFilePath.isEmpty()) { + return winrt::single_threaded_vector(std::move(properties)); + } + + const auto itemPathString = QString::fromStdString(winrt::to_string(itemPath)); + + const auto isItemPathValid = [&itemPathString]() { + if (itemPathString.isEmpty()) { + return false; + } + + const auto itemPathSplit = itemPathString.split(QStringLiteral("\\"), Qt::SkipEmptyParts); + + if (itemPathSplit.size() > 0) { + const auto itemName = itemPathSplit.last(); + return !itemName.startsWith(QStringLiteral(".sync_")) && !itemName.startsWith(QStringLiteral(".owncloudsync.log")); + } + + return true; + }(); + + if (!isItemPathValid) { + return winrt::single_threaded_vector(std::move(properties)); + } + + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + + const auto states = customStateProviderIpc.fetchCustomStatesForFile(itemPathString); + + for (const auto &state : states) { + const auto stateValue = state.canConvert() ? state.toInt() : -1; + + if (stateValue >= 0) { + auto foundAvalability = _stateIconsAvailibility.constFind(stateValue); + if (foundAvalability == std::cend(_stateIconsAvailibility)) { + const auto hIcon = ExtractIcon(NULL, _dllFilePath.toStdWString().c_str(), stateValue); + _stateIconsAvailibility[stateValue] = hIcon != NULL; + if (hIcon) { + DestroyIcon(hIcon); + } + foundAvalability = _stateIconsAvailibility.constFind(stateValue); + } + + if (!foundAvalability.value()) { + continue; + } + + winrt::Windows::Storage::Provider::StorageProviderItemProperty itemProperty; + itemProperty.Id(stateValue); + itemProperty.Value(QString("Value%1").arg(stateValue).toStdWString()); + itemProperty.IconResource(QString(_dllFilePath + QString(",%1").arg(QString::number(stateValue))).toStdWString()); + properties.push_back(std::move(itemProperty)); + } + } + + return winrt::single_threaded_vector(std::move(properties)); +} +void CustomStateProvider::setDllFilePath(LPCTSTR dllFilePath) +{ + _dllFilePath = QString::fromWCharArray(dllFilePath); + if (!_dllFilePath.endsWith(QStringLiteral(".dll"))) { + _dllFilePath.clear(); + } +} + +QString CustomStateProvider::_dllFilePath; +} diff --git a/src/libsync/vfs/cfapi/shellext/customstateprovider.h b/src/libsync/vfs/cfapi/shellext/customstateprovider.h new file mode 100644 index 000000000..30c2045e9 --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/customstateprovider.h @@ -0,0 +1,46 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once +#include "Generated/CfApiShellExtensions/customstateprovider.g.h" +#include "config.h" +#include +#include +#include +#include + +namespace winrt::CfApiShellExtensions::implementation { +class __declspec(uuid(CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID)) CustomStateProvider + : public CustomStateProviderT +{ +public: + CustomStateProvider(); + virtual ~CustomStateProvider(); + Windows::Foundation::Collections::IIterable + GetItemProperties(_In_ hstring const &itemPath); + + static void setDllFilePath(LPCTSTR dllFilePath); + +private: + static QString _dllFilePath; + static HINSTANCE _dllhInstance; + QMap _stateIconsAvailibility; +}; +} + +namespace winrt::CfApiShellExtensions::factory_implementation { +struct CustomStateProvider : CustomStateProviderT +{ +}; +} diff --git a/src/libsync/vfs/cfapi/shellext/customstateprovideripc.cpp b/src/libsync/vfs/cfapi/shellext/customstateprovideripc.cpp new file mode 100644 index 000000000..50939faec --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/customstateprovideripc.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "customstateprovideripc.h" +#include "common/shellextensionutils.h" +#include "ipccommon.h" +#include +namespace { +// we don't want to block the Explorer for too long (default is 30K, so we'd keep it at 10K, except QLocalSocket::waitForDisconnected()) +constexpr auto socketTimeoutMs = 10000; +} + +namespace VfsShellExtensions { + +CustomStateProviderIpc::~CustomStateProviderIpc() +{ + disconnectSocketFromServer(); +} + +QVariantList CustomStateProviderIpc::fetchCustomStatesForFile(const QString &filePath) +{ + const auto sendMessageAndReadyRead = [this](QVariantMap &message) { + _localSocket.write(VfsShellExtensions::Protocol::createJsonMessage(message)); + return _localSocket.waitForBytesWritten(socketTimeoutMs) && _localSocket.waitForReadyRead(socketTimeoutMs); + }; + + const auto mainServerName = getServerNameForPath(filePath); + + if (mainServerName.isEmpty()) { + return {}; + } + + // #1 Connect to the local server + if (!connectSocketToServer(mainServerName)) { + return {}; + } + + auto messageRequestCustomStatesForFile = QVariantMap { + { + VfsShellExtensions::Protocol::CustomStateProviderRequestKey, + QVariantMap { + { VfsShellExtensions::Protocol::FilePathKey, filePath } + } + } + }; + + // #2 Request custom states for a 'filePath' + if (!sendMessageAndReadyRead(messageRequestCustomStatesForFile)) { + return {}; + } + + // #3 Receive custom states as JSON + const auto message = QJsonDocument::fromJson(_localSocket.readAll()).toVariant().toMap(); + if (!VfsShellExtensions::Protocol::validateProtocolVersion(message) || !message.contains(VfsShellExtensions::Protocol::CustomStateDataKey)) { + return {}; + } + const auto customStates = message.value(VfsShellExtensions::Protocol::CustomStateDataKey).toMap().value(VfsShellExtensions::Protocol::CustomStateStatesKey).toList(); + disconnectSocketFromServer(); + + return customStates; +} + +bool CustomStateProviderIpc::disconnectSocketFromServer() +{ + const auto isConnectedOrConnecting = _localSocket.state() == QLocalSocket::ConnectedState || _localSocket.state() == QLocalSocket::ConnectingState; + if (isConnectedOrConnecting) { + _localSocket.disconnectFromServer(); + const auto isNotConnected = _localSocket.state() == QLocalSocket::UnconnectedState || _localSocket.state() == QLocalSocket::ClosingState; + return isNotConnected || _localSocket.waitForDisconnected(); + } + return true; +} + +QString CustomStateProviderIpc::getServerNameForPath(const QString &filePath) +{ + if (!overrideServerName.isEmpty()) { + return overrideServerName; + } + + return findServerNameForPath(filePath); +} + +bool CustomStateProviderIpc::connectSocketToServer(const QString &serverName) +{ + if (!disconnectSocketFromServer()) { + return false; + } + _localSocket.setServerName(serverName); + _localSocket.connectToServer(); + return _localSocket.state() == QLocalSocket::ConnectedState || _localSocket.waitForConnected(socketTimeoutMs); +} +QString CustomStateProviderIpc::overrideServerName = {}; +} diff --git a/src/libsync/vfs/cfapi/shellext/customstateprovideripc.h b/src/libsync/vfs/cfapi/shellext/customstateprovideripc.h new file mode 100644 index 000000000..bf21d91f7 --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/customstateprovideripc.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include +#include +#include + +namespace VfsShellExtensions { +class CustomStateProviderIpc +{ +public: + CustomStateProviderIpc() = default; + ~CustomStateProviderIpc(); + + QVariantList fetchCustomStatesForFile(const QString &filePath); + +private: + bool connectSocketToServer(const QString &serverName); + bool disconnectSocketFromServer(); + + static QString getServerNameForPath(const QString &filePath); + +public: + // for unit tests (as Registry does not work on a CI VM) + static QString overrideServerName; + +private: + QLocalSocket _localSocket; +}; +} diff --git a/src/libsync/vfs/cfapi/shellext/dllmain.cpp b/src/libsync/vfs/cfapi/shellext/dllmain.cpp index c9edff1dd..07a8dea7f 100644 --- a/src/libsync/vfs/cfapi/shellext/dllmain.cpp +++ b/src/libsync/vfs/cfapi/shellext/dllmain.cpp @@ -13,16 +13,20 @@ */ #include "cfapishellintegrationclassfactory.h" +#include "customstateprovider.h" #include "thumbnailprovider.h" #include long dllReferenceCount = 0; +long dllObjectsCount = 0; HINSTANCE instanceHandle = NULL; +HRESULT CustomStateProvider_CreateInstance(REFIID riid, void **ppv); HRESULT ThumbnailProvider_CreateInstance(REFIID riid, void **ppv); const VfsShellExtensions::ClassObjectInit listClassesSupported[] = { + {&__uuidof(winrt::CfApiShellExtensions::implementation::CustomStateProvider), CustomStateProvider_CreateInstance}, {&__uuidof(VfsShellExtensions::ThumbnailProvider), ThumbnailProvider_CreateInstance} }; @@ -30,6 +34,9 @@ STDAPI_(BOOL) DllMain(HINSTANCE hInstance, DWORD dwReason, void *) { if (dwReason == DLL_PROCESS_ATTACH) { instanceHandle = hInstance; + wchar_t dllFilePath[_MAX_PATH] = {0}; + ::GetModuleFileName(instanceHandle, dllFilePath, _MAX_PATH); + winrt::CfApiShellExtensions::implementation::CustomStateProvider::setDllFilePath(dllFilePath); DisableThreadLibraryCalls(hInstance); } @@ -38,7 +45,7 @@ STDAPI_(BOOL) DllMain(HINSTANCE hInstance, DWORD dwReason, void *) STDAPI DllCanUnloadNow() { - return dllReferenceCount == 0 ? S_OK : S_FALSE; + return (dllReferenceCount == 0 && dllObjectsCount == 0) ? S_OK : S_FALSE; } STDAPI DllGetClassObject(REFCLSID clsid, REFIID riid, void **ppv) @@ -46,6 +53,16 @@ STDAPI DllGetClassObject(REFCLSID clsid, REFIID riid, void **ppv) return VfsShellExtensions::CfApiShellIntegrationClassFactory::CreateInstance(clsid, listClassesSupported, ARRAYSIZE(listClassesSupported), riid, ppv); } +HRESULT CustomStateProvider_CreateInstance(REFIID riid, void **ppv) +{ + try { + const auto customStateProvider = winrt::make_self(); + return customStateProvider->QueryInterface(riid, ppv); + } catch (_com_error exc) { + return exc.Error(); + } +} + HRESULT ThumbnailProvider_CreateInstance(REFIID riid, void **ppv) { auto *thumbnailProvider = new (std::nothrow) VfsShellExtensions::ThumbnailProvider(); diff --git a/src/libsync/vfs/cfapi/shellext/ipccommon.cpp b/src/libsync/vfs/cfapi/shellext/ipccommon.cpp new file mode 100644 index 000000000..a86b06b9d --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/ipccommon.cpp @@ -0,0 +1,50 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "ipccommon.h" +#include "common/shellextensionutils.h" +#include "common/utility.h" +#include + +namespace VfsShellExtensions { +QString findServerNameForPath(const QString &filePath) +{ + // SyncRootManager Registry key contains all registered folders for Cf API. It will give us the correct name of the + // current app based on the folder path + QString serverName; + constexpr auto syncRootManagerRegKey = R"(SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager)"; + + if (OCC::Utility::registryKeyExists(HKEY_LOCAL_MACHINE, syncRootManagerRegKey)) { + OCC::Utility::registryWalkSubKeys( + HKEY_LOCAL_MACHINE, syncRootManagerRegKey, [&](HKEY, const QString &syncRootId) { + const QString syncRootIdUserSyncRootsRegistryKey = + syncRootManagerRegKey + QStringLiteral("\\") + syncRootId + QStringLiteral(R"(\UserSyncRoots\)"); + OCC::Utility::registryWalkValues(HKEY_LOCAL_MACHINE, syncRootIdUserSyncRootsRegistryKey, + [&](const QString &userSyncRootName, bool *done) { + const auto userSyncRootValue = QDir::fromNativeSeparators(OCC::Utility::registryGetKeyValue( + HKEY_LOCAL_MACHINE, syncRootIdUserSyncRootsRegistryKey, userSyncRootName) + .toString()); + if (QDir::fromNativeSeparators(filePath).startsWith(userSyncRootValue)) { + const auto syncRootIdSplit = syncRootId.split(QLatin1Char('!'), Qt::SkipEmptyParts); + if (!syncRootIdSplit.isEmpty()) { + serverName = VfsShellExtensions::serverNameForApplicationName(syncRootIdSplit.first()); + *done = true; + } + } + }); + }); + } + return serverName; +} +} diff --git a/src/libsync/vfs/cfapi/shellext/ipccommon.h b/src/libsync/vfs/cfapi/shellext/ipccommon.h new file mode 100644 index 000000000..9b78787e3 --- /dev/null +++ b/src/libsync/vfs/cfapi/shellext/ipccommon.h @@ -0,0 +1,21 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include + +namespace VfsShellExtensions { +QString findServerNameForPath(const QString &filePath); +} diff --git a/src/libsync/vfs/cfapi/shellext/thumbnailprovider.cpp b/src/libsync/vfs/cfapi/shellext/thumbnailprovider.cpp index 18c0ba905..f3eb9de24 100644 --- a/src/libsync/vfs/cfapi/shellext/thumbnailprovider.cpp +++ b/src/libsync/vfs/cfapi/shellext/thumbnailprovider.cpp @@ -48,6 +48,8 @@ #include #include +extern long dllObjectsCount; + namespace VfsShellExtensions { std::pair hBitmapAndAlphaTypeFromData(const QByteArray &thumbnailData) @@ -93,8 +95,13 @@ std::pair hBitmapAndAlphaTypeFromData(const QByteArray & ThumbnailProvider::ThumbnailProvider() : _referenceCount(1) { + InterlockedIncrement(&dllObjectsCount); } +ThumbnailProvider::~ThumbnailProvider() +{ + InterlockedDecrement(&dllObjectsCount); +} IFACEMETHODIMP ThumbnailProvider::QueryInterface(REFIID riid, void **ppv) { static const QITAB qit[] = { diff --git a/src/libsync/vfs/cfapi/shellext/thumbnailprovider.h b/src/libsync/vfs/cfapi/shellext/thumbnailprovider.h index 66256c3a0..3e5e7f85f 100644 --- a/src/libsync/vfs/cfapi/shellext/thumbnailprovider.h +++ b/src/libsync/vfs/cfapi/shellext/thumbnailprovider.h @@ -30,7 +30,7 @@ class __declspec(uuid(CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID)) ThumbnailProvi public: ThumbnailProvider(); - virtual ~ThumbnailProvider() = default; + virtual ~ThumbnailProvider(); IFACEMETHODIMP QueryInterface(REFIID riid, void **ppv); diff --git a/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp b/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp index 8a6d057b5..2ee66d81c 100644 --- a/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp +++ b/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp @@ -14,14 +14,11 @@ #include "thumbnailprovideripc.h" #include "common/shellextensionutils.h" -#include "common/utility.h" +#include "ipccommon.h" #include #include #include #include -#include -#include -#include namespace { // we don't want to block the Explorer for too long (default is 30K, so we'd keep it at 10K, except QLocalSocket::waitForDisconnected()) constexpr auto socketTimeoutMs = 10000; @@ -61,7 +58,7 @@ QByteArray ThumbnailProviderIpc::fetchThumbnailForFile(const QString &filePath, { VfsShellExtensions::Protocol::ThumbnailProviderRequestKey, QVariantMap { - {VfsShellExtensions::Protocol::ThumbnailProviderRequestFilePathKey, filePath}, + {VfsShellExtensions::Protocol::FilePathKey, filePath}, {VfsShellExtensions::Protocol::ThumbnailProviderRequestFileSizeKey, QVariantMap{{QStringLiteral("width"), size.width()}, {QStringLiteral("height"), size.height()}}} } } @@ -99,26 +96,8 @@ QString ThumbnailProviderIpc::getServerNameForPath(const QString &filePath) if (!overrideServerName.isEmpty()) { return overrideServerName; } - // SyncRootManager Registry key contains all registered folders for Cf API. It will give us the correct name of the current app based on the folder path - QString serverName; - constexpr auto syncRootManagerRegKey = R"(SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager)"; - if (OCC::Utility::registryKeyExists(HKEY_LOCAL_MACHINE, syncRootManagerRegKey)) { - OCC::Utility::registryWalkSubKeys(HKEY_LOCAL_MACHINE, syncRootManagerRegKey, [&](HKEY, const QString &syncRootId) { - const QString syncRootIdUserSyncRootsRegistryKey = syncRootManagerRegKey + QStringLiteral("\\") + syncRootId + QStringLiteral(R"(\UserSyncRoots\)"); - OCC::Utility::registryWalkValues(HKEY_LOCAL_MACHINE, syncRootIdUserSyncRootsRegistryKey, [&](const QString &userSyncRootName, bool *done) { - const auto userSyncRootValue = QDir::fromNativeSeparators(OCC::Utility::registryGetKeyValue(HKEY_LOCAL_MACHINE, syncRootIdUserSyncRootsRegistryKey, userSyncRootName).toString()); - if (QDir::fromNativeSeparators(filePath).startsWith(userSyncRootValue)) { - const auto syncRootIdSplit = syncRootId.split(QLatin1Char('!'), Qt::SkipEmptyParts); - if (!syncRootIdSplit.isEmpty()) { - serverName = VfsShellExtensions::serverNameForApplicationName(syncRootIdSplit.first()); - *done = true; - } - } - }); - }); - } - return serverName; + return findServerNameForPath(filePath); } bool ThumbnailProviderIpc::connectSocketToServer(const QString &serverName) diff --git a/src/libsync/vfs/cfapi/vfs_cfapi.cpp b/src/libsync/vfs/cfapi/vfs_cfapi.cpp index 3893f6393..fda83f77b 100644 --- a/src/libsync/vfs/cfapi/vfs_cfapi.cpp +++ b/src/libsync/vfs/cfapi/vfs_cfapi.cpp @@ -40,12 +40,16 @@ const auto rootKey = HKEY_CURRENT_USER; bool registerShellExtension() { + const QList> listExtensions = { + {CFAPI_SHELLEXT_THUMBNAIL_HANDLER_DISPLAY_NAME, CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG}, + {CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_DISPLAY_NAME, CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG} + }; + // assume CFAPI_SHELL_EXTENSIONS_LIB_NAME is always in the same folder as the main executable // assume CFAPI_SHELL_EXTENSIONS_LIB_NAME is always in the same folder as the main executable const auto shellExtensionDllPath = QDir::toNativeSeparators(QString(QCoreApplication::applicationDirPath() + QStringLiteral("/") + CFAPI_SHELL_EXTENSIONS_LIB_NAME + QStringLiteral(".dll"))); if (!QFileInfo::exists(shellExtensionDllPath)) { Q_ASSERT(false); - qCWarning(lcCfApi) << "Register CfAPI shell extensions failed. Dll does not exist in " - << QCoreApplication::applicationDirPath(); + qCWarning(lcCfApi) << "Register CfAPI shell extensions failed. Dll does not exist in " << QCoreApplication::applicationDirPath(); return false; } @@ -57,20 +61,22 @@ bool registerShellExtension() return false; } - const QString clsidPath = QString() % clsIdRegKey % CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG; - const QString clsidServerPath = clsidPath % R"(\InprocServer32)"; + for (const auto extension : listExtensions) { + const QString clsidPath = QString() % clsIdRegKey % extension.second; + const QString clsidServerPath = clsidPath % R"(\InprocServer32)"; - if (!OCC::Utility::registrySetKeyValue(rootKey, clsidPath, QStringLiteral("AppID"), REG_SZ, CFAPI_SHELLEXT_APPID_REG)) { - return false; - } - if (!OCC::Utility::registrySetKeyValue(rootKey, clsidPath, {}, REG_SZ, CFAPI_SHELLEXT_THUMBNAIL_HANDLER_DISPLAY_NAME)) { - return false; - } - if (!OCC::Utility::registrySetKeyValue(rootKey, clsidServerPath, {}, REG_SZ, shellExtensionDllPath)) { - return false; - } - if (!OCC::Utility::registrySetKeyValue(rootKey, clsidServerPath, QStringLiteral("ThreadingModel"), REG_SZ, QStringLiteral("Apartment"))) { - return false; + if (!OCC::Utility::registrySetKeyValue(rootKey, clsidPath, QStringLiteral("AppID"), REG_SZ, CFAPI_SHELLEXT_APPID_REG)) { + return false; + } + if (!OCC::Utility::registrySetKeyValue(rootKey, clsidPath, {}, REG_SZ, extension.first)) { + return false; + } + if (!OCC::Utility::registrySetKeyValue(rootKey, clsidServerPath, {}, REG_SZ, shellExtensionDllPath)) { + return false; + } + if (!OCC::Utility::registrySetKeyValue(rootKey, clsidServerPath, QStringLiteral("ThreadingModel"), REG_SZ, QStringLiteral("Apartment"))) { + return false; + } } return true; @@ -83,9 +89,16 @@ void unregisterShellExtensions() OCC::Utility::registryDeleteKeyTree(rootKey, appIdPath); } - const QString clsidPath = QString() % clsIdRegKey % CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG; - if (OCC::Utility::registryKeyExists(rootKey, clsidPath)) { - OCC::Utility::registryDeleteKeyTree(rootKey, clsidPath); + const QStringList listExtensions = { + CFAPI_SHELLEXT_CUSTOM_STATE_HANDLER_CLASS_ID_REG, + CFAPI_SHELLEXT_THUMBNAIL_HANDLER_CLASS_ID_REG + }; + + for (const auto extension : listExtensions) { + const QString clsidPath = QString() % clsIdRegKey % extension; + if (OCC::Utility::registryKeyExists(rootKey, clsidPath)) { + OCC::Utility::registryDeleteKeyTree(rootKey, clsidPath); + } } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d7ff56515..c3fe37a99 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -76,7 +76,7 @@ if (WIN32) nextcloud_add_test(SyncCfApi) nextcloud_add_test(CfApiShellExtensionsIPC) - target_sources(CfApiShellExtensionsIPCTest PRIVATE "${CMAKE_SOURCE_DIR}/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp") + target_sources(CfApiShellExtensionsIPCTest PRIVATE "${CMAKE_SOURCE_DIR}/src/libsync/vfs/cfapi/shellext/thumbnailprovideripc.cpp" "${CMAKE_SOURCE_DIR}/src/libsync/vfs/cfapi/shellext/customstateprovideripc.cpp" "${CMAKE_SOURCE_DIR}/src/libsync/vfs/cfapi/shellext/ipccommon.cpp") elseif(LINUX) # elseif(LINUX OR APPLE) nextcloud_add_test(SyncXAttr) endif() diff --git a/test/testcfapishellextensionsipc.cpp b/test/testcfapishellextensionsipc.cpp index d9fe3cc0d..4aec97b57 100644 --- a/test/testcfapishellextensionsipc.cpp +++ b/test/testcfapishellextensionsipc.cpp @@ -5,22 +5,121 @@ * */ +#include +#include +#include +#include +#include +#include "config.h" +#include +#include +#include +#include +#include +#include "syncenginetestutils.h" +#include "testhelper.h" +#include +#include #include #include #include -#include "syncenginetestutils.h" -#include "common/vfs.h" -#include "common/shellextensionutils.h" -#include "config.h" -#include -#include "folderman.h" -#include "account.h" -#include "accountstate.h" -#include "accountmanager.h" -#include "testhelper.h" -#include "vfs/cfapi/shellext/thumbnailprovideripc.h" -#include "shellextensionsserver.h" +namespace { +static constexpr auto roootFolderName = "A"; +static constexpr auto imagesFolderName = "photos"; +static constexpr auto filesFolderName = "files"; + +static const QByteArray fakeNoSharesResponse = R"({"ocs":{"data":[],"meta":{"message":"OK","status":"ok","statuscode":200}}})"; + +static const QByteArray fakeSharedFilesResponse = R"({"ocs":{"data":[{ + "attributes": null, + "can_delete": true, + "can_edit": true, + "displayname_file_owner": "admin", + "displayname_owner": "admin", + "expiration": null, + "file_parent": 2981, + "file_source": 3538, + "file_target": "/test_shared_file.txt", + "has_preview": true, + "hide_download": 0, + "id": "36", + "item_source": 3538, + "item_type": "file", + "label": null, + "mail_send": 0, + "mimetype": "text/plain", + "note": "", + "parent": null, + "path": "A/files/test_shared_file.txt", + "permissions": 19, + "share_type": 0, + "share_with": "newstandard", + "share_with_displayname": "newstandard", + "share_with_displayname_unique": "newstandard", + "status": { + "clearAt": null, + "icon": null, + "message": null, + "status": "offline" + }, + "stime": 1662995777, + "storage": 2, + "storage_id": "home::admin", + "token": null, + "uid_file_owner": "admin", + "uid_owner": "admin" + }, + { + "attributes": null, + "can_delete": true, + "can_edit": true, + "displayname_file_owner": "admin", + "displayname_owner": "admin", + "expiration": null, + "file_parent": 2981, + "file_source": 3538, + "file_target": "/test_shared_and_locked_file.txt", + "has_preview": true, + "hide_download": 0, + "id": "36", + "item_source": 3538, + "item_type": "file", + "label": null, + "mail_send": 0, + "mimetype": "text/plain", + "note": "", + "parent": null, + "path": "A/files/test_shared_and_locked_file.txt", + "permissions": 19, + "share_type": 0, + "share_with": "newstandard", + "share_with_displayname": "newstandard", + "share_with_displayname_unique": "newstandard", + "status": { + "clearAt": null, + "icon": null, + "message": null, + "status": "offline" + }, + "stime": 1662995777, + "storage": 2, + "storage_id": "home::admin", + "token": null, + "uid_file_owner": "admin", + "uid_owner": "admin" + } + ], + "meta": { + "message": "OK", + "status": "ok", + "statuscode": 200 + } + } +})"; + +static constexpr auto shellExtensionServerOverrideIntervalMs = 1000LL * 2LL; +} using namespace OCC; @@ -38,21 +137,39 @@ class TestCfApiShellExtensionsIPC : public QObject QScopedPointer _shellExtensionsServer; - QStringList dummmyImageNames = { - "A/photos/imageJpg.jpg", - "A/photos/imagePng.png", - "A/photos/imagePng.bmp", + const QStringList dummmyImageNames = { + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(imagesFolderName) + QLatin1Char('/') + QStringLiteral("imageJpg.jpg")) }, + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(imagesFolderName) + QLatin1Char('/') + QStringLiteral("imagePng.png")) }, + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(imagesFolderName) + QLatin1Char('/') + QStringLiteral("imagePng.bmp")) } }; QMap dummyImages; QString currentImage; + struct FileStates + { + bool _isShared = false; + bool _isLocked = false; + }; + + const QMap dummyFileStates = { + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(filesFolderName) + QLatin1Char('/') + QStringLiteral("test_locked_file.txt")), { false, true } }, + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(filesFolderName) + QLatin1Char('/') + QStringLiteral("test_shared_file.txt")), { true, false } }, + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(filesFolderName) + QLatin1Char('/') + QStringLiteral("test_shared_and_locked_file.txt")), { true, true }}, + { QString(QString(roootFolderName) + QLatin1Char('/') + QString(filesFolderName) + QLatin1Char('/') + QStringLiteral("test_non_shared_and_non_locked_file.txt")), { false, false }} + }; + +public: + static bool replyWithNoShares; + private slots: void initTestCase() { VfsShellExtensions::ThumbnailProviderIpc::overrideServerName = VfsShellExtensions::serverNameForApplicationNameDefault(); + VfsShellExtensions::CustomStateProviderIpc::overrideServerName = VfsShellExtensions::serverNameForApplicationNameDefault(); _shellExtensionsServer.reset(new ShellExtensionsServer); + _shellExtensionsServer->setIsSharedInvalidationInterval(shellExtensionServerOverrideIntervalMs); for (const auto &dummyImageName : dummmyImageNames) { const auto extension = dummyImageName.split(".").last(); @@ -68,6 +185,16 @@ private slots: dummyImages.insert(dummyImageName, byteArray); } + fakeFolder.remoteModifier().mkdir(roootFolderName); + + fakeFolder.remoteModifier().mkdir(QString(roootFolderName) + QLatin1Char('/') + QString(filesFolderName)); + + fakeFolder.remoteModifier().mkdir(QString(roootFolderName) + QLatin1Char('/') + QString(imagesFolderName)); + + for (const auto &fileStateKey : dummyFileStates.keys()) { + fakeFolder.remoteModifier().insert(fileStateKey, 256); + } + fakeQnam.reset(new FakeQNAM({})); account = OCC::Account::create(); account->setCredentials(new FakeCredentials{fakeQnam.data()}); @@ -86,31 +213,43 @@ private slots: Q_UNUSED(device); QNetworkReply *reply = nullptr; - const auto urlQuery = QUrlQuery(req.url()); - const auto fileId = urlQuery.queryItemValue(QStringLiteral("fileId")); - const auto x = urlQuery.queryItemValue(QStringLiteral("x")).toInt(); - const auto y = urlQuery.queryItemValue(QStringLiteral("y")).toInt(); const auto path = req.url().path(); - if (fileId.isEmpty() || x <= 0 || y <= 0) { - reply = new FakePayloadReply(op, req, {}, nullptr); - } else { - const auto foundImageIt = dummyImages.find(currentImage); - - QByteArray byteArray; - if (foundImageIt != dummyImages.end()) { - byteArray = foundImageIt.value(); - } - - currentImage.clear(); - - auto fakePayloadReply = new FakePayloadReply(op, req, byteArray, nullptr); - + if (path.endsWith(OCC::OcsShareJob::_pathForSharesRequest)) { + const auto jsonReply = TestCfApiShellExtensionsIPC::replyWithNoShares ? fakeNoSharesResponse : fakeSharedFilesResponse; + TestCfApiShellExtensionsIPC::replyWithNoShares = false; + auto fakePayloadReply = new FakePayloadReply(op, req, jsonReply, nullptr); QMap additionalHeaders = { - {QNetworkRequest::KnownHeaders::ContentTypeHeader, "image/jpeg"}}; + {QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"}}; fakePayloadReply->_additionalHeaders = additionalHeaders; - reply = fakePayloadReply; + } else if (path.endsWith(ShellExtensionsServer::getFetchThumbnailPath())) { + const auto urlQuery = QUrlQuery(req.url()); + const auto fileId = urlQuery.queryItemValue(QStringLiteral("fileId")); + const auto x = urlQuery.queryItemValue(QStringLiteral("x")).toInt(); + const auto y = urlQuery.queryItemValue(QStringLiteral("y")).toInt(); + if (fileId.isEmpty() || x <= 0 || y <= 0) { + reply = new FakePayloadReply(op, req, {}, nullptr); + } else { + const auto foundImageIt = dummyImages.find(currentImage); + + QByteArray byteArray; + if (foundImageIt != dummyImages.end()) { + byteArray = foundImageIt.value(); + } + + currentImage.clear(); + + auto fakePayloadReply = new FakePayloadReply(op, req, byteArray, nullptr); + + QMap additionalHeaders = { + {QNetworkRequest::KnownHeaders::ContentTypeHeader, "image/jpeg"}}; + fakePayloadReply->_additionalHeaders = additionalHeaders; + + reply = fakePayloadReply; + } + } else { + reply = new FakePayloadReply(op, req, {}, nullptr); } return reply; @@ -126,6 +265,7 @@ private slots: folder->setVirtualFilesEnabled(true); + QVERIFY(fakeFolder.syncOnce()); QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); ItemCompletedSpy completeSpy(fakeFolder); @@ -135,8 +275,6 @@ private slots: cleanup(); // Create a virtual file for remote files - fakeFolder.remoteModifier().mkdir("A"); - fakeFolder.remoteModifier().mkdir("A/photos"); for (const auto &dummyImageName : dummmyImageNames) { fakeFolder.remoteModifier().insert(dummyImageName, 256); } @@ -198,6 +336,137 @@ private slots: QVERIFY(thumbnailReplyData.isEmpty()); } + void testRequestCustomStates() + { + FolderMan *folderman = FolderMan::instance(); + QVERIFY(folderman); + auto folder = FolderMan::instance()->folderForPath(fakeFolder.localPath()); + QVERIFY(folder); + + folder->setVirtualFilesEnabled(true); + + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + + // just add records from fake folder's journal to real one's to make test work + SyncJournalFileRecord record; + auto realFolder = FolderMan::instance()->folderForPath(fakeFolder.localPath()); + QVERIFY(realFolder); + for (auto it = std::begin(dummyFileStates); it != std::end(dummyFileStates); ++it) { + if (fakeFolder.syncJournal().getFileRecord(it.key(), &record)) { + record._isShared = it.value()._isShared; + if (record._isShared) { + record._remotePerm.setPermission(OCC::RemotePermissions::Permissions::IsShared); + } + record._lockstate._locked = it.value()._isLocked; + if (record._lockstate._locked) { + record._lockstate._lockOwnerId = "admin@example.cloud.com"; + record._lockstate._lockOwnerDisplayName = "Admin"; + record._lockstate._lockOwnerType = static_cast(SyncFileItem::LockOwnerType::UserLock); + record._lockstate._lockTime = QDateTime::currentMSecsSinceEpoch(); + record._lockstate._lockTimeout = 1000 * 60 * 60; + } + QVERIFY(fakeFolder.syncJournal().setFileRecord(record)); + QVERIFY(realFolder->journalDb()->setFileRecord(record)); + } + } + + // #1 Test every file's states fetching. Everything must succeed. + for (auto it = std::cbegin(dummyFileStates); it != std::cend(dummyFileStates); ++it) { + QEventLoop loop; + QVariantList customStates; + std::thread t([&] { + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + customStates = customStateProviderIpc.fetchCustomStatesForFile(fakeFolder.localPath() + it.key()); + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + }); + loop.exec(); + t.detach(); + QVERIFY(!customStates.isEmpty() || (!it.value()._isLocked && !it.value()._isShared)); + } + + // #2 Test wrong file's states fetching. It must fail. + QEventLoop loop; + QVariantList customStates; + std::thread t1([&] { + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + customStates = customStateProviderIpc.fetchCustomStatesForFile(fakeFolder.localPath() + QStringLiteral("A/files/wrong.jpg")); + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + }); + loop.exec(); + t1.detach(); + QVERIFY(customStates.isEmpty()); + + // #3 Test wrong file states fetching. It must fail. + customStates.clear(); + std::thread t2([&] { + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + customStates = customStateProviderIpc.fetchCustomStatesForFile(fakeFolder.localPath() + QStringLiteral("A/files/test_non_shared_and_non_locked_file.txt")); + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + }); + loop.exec(); + t2.detach(); + QVERIFY(customStates.isEmpty()); + + // reset all share states to make sure we'll get new states when fetching + for (auto it = std::begin(dummyFileStates); it != std::end(dummyFileStates); ++it) { + if (fakeFolder.syncJournal().getFileRecord(it.key(), &record)) { + record._remotePerm.unsetPermission(OCC::RemotePermissions::Permissions::IsShared); + record._isShared = false; + QVERIFY(fakeFolder.syncJournal().setFileRecord(record)); + QVERIFY(realFolder->journalDb()->setFileRecord(record)); + } + } + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + // + + // wait enough time to make shares' state invalid + QTest::qWait(shellExtensionServerOverrideIntervalMs + 1000); + + // #4 Test every file's states fetching. Everything must succeed. + for (auto it = std::cbegin(dummyFileStates); it != std::cend(dummyFileStates); ++it) { + QEventLoop loop; + QVariantList customStates; + std::thread t([&] { + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + customStates = customStateProviderIpc.fetchCustomStatesForFile(fakeFolder.localPath() + it.key()); + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + }); + loop.exec(); + t.detach(); + QVERIFY(!customStates.isEmpty() || (!it.value()._isLocked && !it.value()._isShared)); + + if (!customStates.isEmpty()) { + const auto lockedIndex = QString(CUSTOM_STATE_ICON_LOCKED_INDEX).toInt() - QString(CUSTOM_STATE_ICON_INDEX_OFFSET).toInt(); + const auto sharedIndex = QString(CUSTOM_STATE_ICON_SHARED_INDEX).toInt() - QString(CUSTOM_STATE_ICON_INDEX_OFFSET).toInt(); + + if (customStates.contains(lockedIndex) && customStates.contains(sharedIndex)) { + QVERIFY(it.value()._isLocked && it.value()._isShared); + } + if (customStates.contains(lockedIndex)) { + QVERIFY(it.value()._isLocked); + } + if (customStates.contains(sharedIndex)) { + QVERIFY(it.value()._isShared); + } + } + } + + // #5 Test no shares response for a file + QTest::qWait(shellExtensionServerOverrideIntervalMs + 1000); + TestCfApiShellExtensionsIPC::replyWithNoShares = true; + customStates.clear(); + std::thread t3([&] { + VfsShellExtensions::CustomStateProviderIpc customStateProviderIpc; + customStates = customStateProviderIpc.fetchCustomStatesForFile(fakeFolder.localPath() + QStringLiteral("A/files/test_non_shared_and_non_locked_file.txt")); + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + }); + loop.exec(); + t3.detach(); + QVERIFY(customStates.isEmpty()); + } + void cleanupTestCase() { VfsShellExtensions::ThumbnailProviderIpc::overrideServerName.clear(); @@ -212,5 +481,7 @@ private slots: } }; +bool TestCfApiShellExtensionsIPC::replyWithNoShares = false; + QTEST_GUILESS_MAIN(TestCfApiShellExtensionsIPC) #include "testcfapishellextensionsipc.moc" diff --git a/theme/cfapishellext_custom_states/0-locked.svg b/theme/cfapishellext_custom_states/0-locked.svg new file mode 100644 index 000000000..e0722f326 --- /dev/null +++ b/theme/cfapishellext_custom_states/0-locked.svg @@ -0,0 +1 @@ + diff --git a/theme/cfapishellext_custom_states/1-shared.svg b/theme/cfapishellext_custom_states/1-shared.svg new file mode 100644 index 000000000..014392d5a --- /dev/null +++ b/theme/cfapishellext_custom_states/1-shared.svg @@ -0,0 +1 @@ + diff --git a/theme/cfapishellext_custom_states/1024-0-locked.png b/theme/cfapishellext_custom_states/1024-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..073bb7a207a4983242da965045419c4758571a80 GIT binary patch literal 12483 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 KJ{Ip{fAyaeL)M-E literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/1024-1-shared.png b/theme/cfapishellext_custom_states/1024-1-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..637548affcb04f9a62461447eb542cb1353e6fb4 GIT binary patch literal 20840 zcmd_Sd0dmn*EsrwAVE>k*H%F`mv3uZ5mAs;pq2`46;XjCia-LUS=bwH&fMJU8ry{1T1pkZH-V_7>Tg?3FU?4^o^rC-Bix)4t z28|j)yXZlV0iHo2M@}EdLPA3Hz5RUxJ&rJs>j#|nN*mj;2E*23KmWMX=}h`aXZWYl z5ZCTmue!w>|F!Px9~AfhuvdBY&x$Md@s^lt*fu0hoI4?WL=E!I;kDs-!(Km}AWRL- z6Y?KBTeOAcuKB{RA!&u{QI&*U`lP?s7oERy?&>#d&vqC_M?`Pk__}z$ZhS;&-7PFn ztU5H#Qq^0#rS9i4uk!9W>1>1w(E5MwKa9t9YqWv=mL&t!Warg1wYIql$Em0&U+rA| z`*r?=iGJJMxNdG;_uOnezB$}l#$T;hVVK zTsPNFBcm0*S4jXhl0>07cuS}<4csv8BG%FDhYzPIDe&({nYbv77|0R%d(j;7%N~_* z!j4)EWL^q!E_V~&mGJRek3JD`#sI@aQ?u^~GkbekU~1i`f-v0x^Y1Ab`xi{lY2wZDc!W`SbPhOMhnnLU$i$3b=EXD5TW>FTHU0fZUE2CNqSV=^z0yF z;RiMT!jVQlaR?{>elxYZ^>jB6nnhOH{w1Puf@rEVc|-FIn3KB+5mE71nYa7*GD?`Y5w$BleJrH zh;LAU)+Y|UDAV}zL++hU(tWJ`Fx0o>HlfL}_KS$QpS0h^DZ0qq0$Pp#RN*$8l&y2B z&MR_q{{mWkCSPS&x7wPpf9X6Zyj!vZnyCHhL^z`G)3+?&PN&Ea*1w>&?o}h#WP4eI zBXLYzNVI}Pm~D03sNYNSCu%be%;j2ebou(qz;VUyPim!OY8-xkmzBA)hqip@7>``QH5=y(v@#uqg%Zt}K!EmrA>~Fl&OG+LXWLZo zG*G$T41^>4ufqqVzciZM76kFRWp_$~gO(dtuK?)a&ecx*y_0Govm@Ezq+5pO%4w`K zLC`_JR=^p*zu?Du*7A*k#ej2k{?P7T+1sEUZ0!Ny5Gx%d*%!@Q2Lvr|QP{#OyIpcB zXn9jLbg*3~{~*4?uh~-0ZvJ?Y*v*-aSA8x~haSOhs{%r?Ke-qZ8$^!9Hz}8|M%gsD#KCPD5?d6>4zeb^BtEq~Y$Oj`~EnnMl-0zn~1TClk z?#{pE-f3j6O!0s*qC!)?1SiaZopaK1d-9XSMAhd*hS0;%)bc*LkZEDA{6~Ou^jB$^ z%fKIKA(=~Hmfe$Tfv23sSMb)X7-pE9`*dwiRH1}u*vSr`i6br(FGDRvO<)Z$2Ct5@ zz!IA%4PXi5mx*5c0186|abiHTrbo69E*Jf5c{_|D&a|WSzC(PZWQ}vn0)vzp?zi4P zdNBF%nVIP#WO@4g1E|(U=8DWix15EgC7wac_tqfhQm$>e@7DII&6-i#aVYX<@xz9l zrdIL+e_keT;JdP7Fv?T|G08EBQ+*zZOE1AXz5>yrP-mTC6!;#Em`FFm+v z92!jU!bk~Y@8_K?ndnYFASfL>5oRdbZAWL9V$k5L_X1&7 zEG<|@(B95h9M~ZUJH;9sT)W!~T1DAXy7+FxI(B^658}Qo8@-H4DyYU=6>ju4b47y} zUe4n@o`&|Z+z!~pqU^(NLS6|YXgMQAGtF(qOku;$q*rKr826441C8?)Z!@|-={@NN zVXIX;7O@u7^9R zQV?S48+1l;Plq*5$Rq86Hp{-YAd#A1e`2c{sCkgdiJ!#>(gj? zn(QpAI5*5mTn4z$E`ttLg2JuW)>dfo_M#0?%RyfH0<_ROwelD44`p;>!$Cgc6r!zR zXJLjM{!cs3#spQ8-t%69ghvDZ_Ev;H$dVHl*mzQ3^mRwqr;{SYNGs zx8D(%NOUP;5-i|c&%wtI3GmI_k!&RNn)@)g7RzCVH~NseEzDna$R&HG%&OK}psh-C zw*-!m73?`vb}|TKH-0$RKq@?oRHviegHr^{#Z1+47J3Bz+pGXFE}Z>(S&MDGt~O(A zDF8h&8J3BRw>{ml2&uSflb2I~hPFZvoA=6aZCFXyj1<@MbWJkeF z$+wPd>_xjgr1)-F{or!UYw+!k>V#?p5;FT+0|}${m}QY`Zor(-@Ta`tiY+B&6Po9~ z`2kmVR|9ipk%r_1>lABvaBZLm$ePWm2g^Tl(SdWz*!+bSGNt>do@YD%ycw*8FkhdP ziTt->rmDy=e7H)GLAmn&$iQRL#*B|7B6;81YL1C6XE;^)r9MZv9JiK+%0^+}2tokyXR##PyXJ`-WN6gd@tRTv2Vbt@D}UJnN_%y%L`6hjlTXDLbV^N6<%>uk}{PQMi!rwSffj2%V27EKeX zV+6~BU5TEOBd-6`T9JI^(TrCnlHbR}JN{>9`6BWv?}>VJ#IoNXoR4KNeqALS`Hc7_ z|Cz#nM($vO%9DICm<5a>c9@G!$iF>6cE6HD(1`h_ zlQ6-L|F4LP7^3x?7h2I-EZA{HwtT7HZ_C&qbgbpc%V=dKkV1t6lEU#~PDwHPP(wk|Tf1A^1wA zZqLk8Ozw<)Wwd~898i{%n!Y05TC~uDsYA5MCO6EG({(iE2$+WeiZXc{&EbRQnA+8+ z#$<+22b|$d+V&f4J9cOzz!y&!edsSo!tVd2PfcLnobb0+P2QQH(1pXW`^ku#&_8C# zd5O!|2+|e?=5@2cQlK+jqz<&!J|Ll3QIzdHPcj~Ng|24@5hHr%D|H!hiPY83AqlG6 zQqi>Hw=?88@kJvUGBcu*6Z540-gp>r0ezl?@%=9`Mxy)!^$jgUeY?S>tBV6%Wn+0d z^vE+oSIy9|$25SsXTw8MW#z|!FlHEV-QJf9JLLs@GNRGvz?XnPZV_Gs^1@~?g()kO zF$GL36Y!Nr-YL0Xay;m2u^gWR;OqQA@m?5zh7f=ZS~l0oy05@of*{oQ*St)K%nQ4>XlQOXP+f0l>GOk%5^1|*tlhKC(ornh_-&ZHgL>`!^5M<8Gi=~ZgYadPU1@!H( zTq2>+s#dlb?ay{w_`LM=1fUOu)|Rn1 z%BWU$7Mc3JKLSS`9f!5^tqe@7sL0>wNUPLk7fV9C7u1kj!(0sv1_p<-j*IQUqd`Jt z1AyO$N$-XFe}iVz^#P#$&nDslZUOF{Cvd_D1wwt(qSN|iajgLV9+p%P@1JXxh1`5U zB)4GDLbl)3HSY!5bC_jBj$|yG*pJWIQd$=wB-0?BmM>-NYh`bMRCecWpgcK35Edl< z72LuLFm+25Fc{om{mW$e0W=~UEw*#BlLOCRSNm8p8Z`>a#b}NUPnF#OtIQrnNESmP zWo&SHDT)YEtbzL7ryRvCM9DL=U}PauwxFg-srN#2C6HE=2$X&m<^f)BQJm^SQvt@{7Qoz_8^FiG^Bo4wVxIyD>>JPJfS@O!J{Wai7p}qqbvSYqSOvc5k7=}ep1)RljE6d~ zdJHuQhdDXaXmeA_^q+7hCjA7MhbI~ce)eqUSD+zDpqkilppW=Ht_k5VoCDy20NHuX z8Mq3!TSxo+N?1`wGGf+n;b*a7mJRbXcr}dGs3kD!u4JlVo#}R@YnOl|3orxM z0878f<lH(x&f-T#GK*T+2rM{`(xxsFTn>e{MJIhx;ma$WE%z6KJ|}(HEMO)rnARsd zpY7T2fzznnw?dLDE_Jv?dWp{weK7?B(il*1BYlEmJh}{@rj>VGa(AA}Ba%6>zTbV} zdv=)1&yDhdtW*_D=8UivW>(7RYVC9NR>w1LGj=~Mlw3wzuEhYnY}1ug=~?^IX^t!^ zRmPd#?x!&SBK7pM!kJd$ruV|?*GiaHO)-8Pe~HITMa^FqX6m}waa1@3X$zsHC&tU8 z@0v%C513GkMx^r<_PWRXxeG_fyUTBT?;5@+l>D*Py1ZYte`;p`>d%i7NUHGsm;wW`5~f!x zSJ6-9rf>PxU5{1YWl5^LuwvIBg0ysmAg zDwF}uXhviIRJ`o3}!ja0V4|5blRwcb>I$?Gq zzWl(+#_aO=(Jz`K4D?I6VW31u~AcRY_%xi%o(v-nm<=pdewQ3`*$MUAY@JS zy?5C@E}F9N(z`=PA2*cW`lYiq$hvWZLJKK2E%iD#t&3(ZA#qiyMJAmZALjRD4}9Op zzup!!`U|W_J&)%-&*vZV6c&b2F^itx2n)`%(LyhW>M|Zsvgh^O4HJjY&?iBKZ0uJr zkN$n4yz8U}E3$EaO9jdIK?Ur%a@Wp=nON>WS@~jXQPXbusLi)0%8RaWbbZaQSW24R zM?E9&NUS+3e+=HP>ky6mFu3(q)(Sz{9N zhpnf!$tAkGFIr-iB{8wL8EYtyK6y)giP-qDn9_1O+4Ez5UEbIl#U0xcK2vP8;5GKl z-fk^FV17I&)h_bMkn&$Siv%zAy~9#&263HnSFc{3hKJtsHx8uf$IFO}#!nJakMZg63lm#Pd*lJ#MO>nS<$k> zz2(!9BcCdqHy3mrh}>Ul_c!oB@4m)N5^X^Bo*%EBo4j3W_;}czim6Pz%C=6owPxHX zDG2+ybb@zSuAlcNGWn2W-6^KT%jSL@<-0jcrH2X1$kjm3cs_{ShMMa!oK2K+H~8uP zJ)m1Z*;qR=d{>{WYyWhf$#!99Z^K4OZA|e*k5~CEH|a6f%fYqG{|exPM$GnT%|V8` zo>LF$1A3f^Q@6YHwBzLSsn9)O%r|kW)ao8O@dG4Yg9PChQ-I}lgxV)3cQnm>%l)SY za^hBmoU_@=XcNo3L+kswTX|V;_uwPAKZ+|gf{-z>f^wUPX zJ2#3Ibk!0(U*lSnh3ZqmjfMTINAZ;(&1Aoi<+;&>LHvdC>@Ck38_yUpcdT}nJ(Pe6 z5iOZN+_<)9L(GC?4QzO$g~R(S?}d4a9SpbL`x< zZMpAnBrL?NRKgRQ?=V%#tI)<1jfGV2jOagnz&tk21TN2Xvg9B;-ngsY;l93(#>oo{ zcZPl|Gcnmd3)xS#KAR0T&mfd`a&7vQFKe`;oLx2(L-?e1sSAGjOnhG3K`36@|4|Kv z(4g^wWM?()>%(Wp^19qz+dt(mxLSi40>|xo+?HZzj)gHsi7q`S+Z=B# z@Lm&bTO8f^eb1l7&Z_yDZ=q1?JRMLH*s~BW6RL1+Ac0C5k%q%UL`U0&bTH3;i%k25W`PysqUg#JAUyGh=lSLM6^>Xgs zFVw5G?r4wU$ebb_rX%&;oQ0gBOl?U2Ee^O}#u>7mT8*NjUA~xJ1v%A!Ho9wYtzePc z;Yb_tBXlw+1pu~9WQZoA;$%M_vS@0c4~OI?b_KcZ8!FsGvjB%X= z(I{t#{PF@EJ=pZ`6TDXT{6BXAC=BIfY%Dv#x8x5(Jz+prePZkp7IRo<`@ zW20ztkEw#d0_rk+q8&~W>^trPRue}O`I{b=o)JUlNbbP)zs4AI+Wv=TWfx%_qZcNx zNhOJbEe=S35To3z=p2ZmWr-5jKKzuRaZ!#PQ$Yb5R~_@s*(OW7OTi z@*Q<3Eq@6mZ%A{J*xD#Y`gJMRKXe-(r>W^`D`LU_hJa>Q;NqV7bei-~DtPN(g0l(9 zBoi-ZS(&5*5lKP8iMkRt`7x0*0CgYC1!8@ys#ldg`-C%8DXTzINdkUFKf`nt_aG>S zLLiB~`7SuTkxWI#(>YrCZ~YLLpcrCr3`umxnri8d7+DXDm||r112=kPA7pF=DEq@y z>L3}^y~l?+Dv0WvpthnVkOb14`xeeGPk>}xk z!rYQ$pnK{lRw0`y2sEs(_Up3Wqj{FLVD$1FDnCy(9#&oxdHxz(F*<(((7OrIyG#xR zgWS4rph2*DB8ZOZnbm3f3l?60mTm|#_9baz!;=->3#1l5kV4CPW2~u99`7h%O^8&N zR~SRAYT`OS{RFMTOQ25hQj0_vqUwvt9~?`Os(>UolzCW~UvdVaB>~!z<(e2*>;PF< zffS91@x}7$D$>fNA(Ifj!#2PyYLrC8d>?3}??xD)$6m(9O%F-;vyL%UF;i`zg^qAc z2oY&I=yRX@|*SWzkd6I}Ex9-(;&fLp71a?gM_Y=+`FA zUj4dv!3adb08wNMX8Oc|%d34Kjn%}0;7!+QVw7n=m?DJ&L@<9bhM@GHS_z{8jVWuz zOjA}v8keDkAliH(kgdRVos+?Eiv9#0V3P|-Oi3A-A}kFyAXb+`5)I9{KweG&IXjGT zT#ICCnRvg;4QlR+>Z$LN}x*jz}k_!e;+bXX9GrfqA*TFOx! zgpG&Ui-kBJ!g;Kz24l#|LLt)_d=YFHJ3dg65v`xg%U4CjP(eeniWaN|PIp+EXCM=H z?^0}PfD0K-#wPUH%2e!mr{gGOfz}sZhN)AuHL;v2D2!k&YY?%8OR>GR5^D_QZ7|RJ z73^zsam3~<8_d83b^Cw-i&52seGt?ObE(20J8&B4@q|S9=PAXWnFOb1+G?;~B3ndY#&L4bjMbJ}Yte5=v>9F)Gr}E( z!J1%_u~iiiF{R5Wa#dxV>L4-Q)pD&2%oQ;od3UvvDALjc$e|+Bpm&P|pONSj@@|*_ zh%NJlC>y0ENIV zq(_3vW9eT*XV{;R$WFfwV(eibRyb4ixw;`bg_N=G>N0kzWfV+4W`ibQuY`p_{vbsS zO%68@Iz~@{hWrlm&{vb$rI5B#w?lBA`lNI%j@6+IYsgbS2N__-FO)=oJp^4zLsYdG zklC*{K%I%Mj4-WL!mgph#fT~TV*K;<4f@)nj_r{bF+30hh8~zfu`d(_w2`P%z6CU> zh*5|#HH?0^NySjA%lEJ0FB0uUvlgCT#vW>qhJ_eH{Q7E;*yj z7wvUXk|72q6jKfKcY*lFc9H@I^ExvM}(xN4Ysp^8R=yrAK!fz3ck;9BoSO!QnGgB(xw zU#%2OV4#GREg~nqE>Oc5N0wp{DoT3SymaL-a(GC$u`g^zbR@loOc8=X|04lANgxcR zCunbB#?_bzvOix#g5&>&{@*aJT1x-A-bxPhVVyetYg8pw@qkL2fnd+YO!?pYbakQZ z#I~c=^FhRl7`qnaqY3*_y=R^vh}5NUxn|Wf>~|gl`@PC*IV6jel`zOIzF7-|4QL?V zw5rkHb{d$e0#rVgAc(&WJJ@OdmFP1dV+UoW!7oSpj|od-MAkmkgKA z6n7PN%o!W86s~)PsMaZ9qMuu^`wqucu%6$20fX;iPzES8V7_K()J;Py<*)@5j1mkW zQzzFH)H$Lq@7xNVsG5|p4RyjuRE!@!4wD;z;rlBCAob4ggtXFHt26d>9IFHJV&?Ow zwE zZdt+(Dww#MO%f$Ln?MO$6V3uzM9itviMd_osPFv=0N)OF9};VQ;KwkUKq=X+f#{@g zitGhLhm)X8k=+!-+rODI?gVR?kN zc!3!8=!1eHI7$l{uEJ8uBk(?2%n~3?>f=Rb$j0vL^Q_?Z7#5s>h=b$O)H2z5{MGPE z5`7C2`y14v%d;vEgY+miVkB?VfWupp%SYtxLds38T)}P(c_PUoJ_35WmK_-ieCRkI z>)^sUrO1AX7}ZKy#*Q)LhdGFUM^JK9a<{;Rt1|h^_gLjav_xqD6#f&-l_h&-hV|%! zCbCyPBr%yL#;%Gpa9Ilz=^z>w16_tRr-09nTh$TJnVH?jSY1slr^*T4bHNfSU;#fQ zgQHPw2UkhKc~`{tqD9pMIt-`1@C}@e$lcFjz}QvbMx8u-NSJTF4b&%H38oONHHH-V z23&rLj>K-1gS`N6E8>tifk3`=4;yx!NVR|@Fpu7EDF|x=2{FG1iW#xsA?7=rEK%uM zhbUgsq6`PdQ8MGPKhKI9IRtP51vwnJpTY{b>{^ek8np(f^L+qu*~sth+;S68tGs7@ zTrSrq)tDUdE1ViHRDMJzNGY6G{{hYKY|aBGpQ4Z)+1a>#YN>8FAvRly$j{L!%_D*CW%hY`^G6;d-# zVAwO=UHG8H7nDT(6%e@e7@1+{pWj7|90fiMu7c*NgL50$wcqk#*gR@V0gJE{x#q5P zS4VAaRID9S?dccMMYXZmZZ9WbRs4Rg^Zig9<3n40on$OekiNp@JJMb3~F8zoe*!Z=WP6kqrE{zh!aa}YY0S0IF`UbrRb)W{{3 zmy|>T(=9(F@_GK|honpFYYh1xx@(a;1iy^M_-m~XPo>F?DNpwnUG3y4}zdx>>9 zRa6=}+IE9c747}7Ml>397j9P;qrnQHk!vv*h^WIeXu>BFUBuW0u*D261ZzV7d>)V2 zvI%1pz%t3h=c0D5d#--|Edx^(Oz#G)fXg$8#yeSCdtx~C+Ubk1q#9uEpdp+ATk`eq z!-X7(m(u~R0QAx&s$l47mDl@oc9?oC0^151fGvEUPuQ;CyWKJMdert|^Te3sdA5qe z7PNhyQ21(!3}r|}1Roa<;|(ALIAValEC}vIhlII92SjjPP{F0YvVp|jbtZzvV%ayY zAi39I0w@QTj+5~M?rOC}%-W6jk9F`=M7no6gIG{{gw=V5_(_JL9%r71>cJ4QN z#kgXw!(r`k(yqE~PW8*_yMM3oz|h)1omS*6`Q~Jv?N1x&&&XSstM+O=Ttd`c8(O3)(6VQ=E~goH^+CRU9E_XG;Z!{9*{_*njwdy*mb}%XGnZA?Y1(u z${xr{5-eie;_=-Y9@Wq8k0}uS#J{iadx#`BSg3?u@$`Wee7*#OGG;u%iwP91cXgMZ zaU?Mx<-zdU%b2ZMv3ZesR!LQ5bGZNf8JXE_^T*~qDBFCBFPEJs^fj%^9<&K3`s%k! z50&Lho>ykdf*g0shT>=6MOl~eN=iz+T^8J*uW`ioJuV14F24KBjIpBYuw}VKv`QBE zB}sJWTZNXB7Mw|APp`(>g*s_}m&Z}e;H#md_mH>)X3rI>KtovY^Xlt&-vgVUgl%q2E-l`JhQk6Q9ox$#jQ1a~+pLsMb!> ziovyMj9p-)sET=IvDA9*t!wqivYr+UmEWepQ;76M43~_3(gTe8_p!-J8z{w<{dwX}$Cd}}|JK}PsJF|Ajz-HX>khL7W-3ozL zf}g~)#~II$m$8l{_)ZyRXng3P1|DB8%4ll{!fLBDqn~Y`Oc_p1`a!2X%u!L@W0mD) zHk>DJ`&`>++{vCk10MFV-rd&h?SWz9=h?!RtSyC}mIK*DGq*8y^)B{S&xS1JmZ&Of z_d#hUF;y8|UprMdihrfqJ;{n@lWJ-X;#UqRrsSt}+VjPw0h8Iv29HKq}(oyr|M-_DF@9;_=amSo~KWcmg* zUR3GOdK1YHK1qR4G~tHr(^CCsz@xx49X1pN#*+`mw@za(2G%n2(_0@Yj+IoppQt@u zGmx4j>fD%c@$WT=xEJ{FV}n-b$R-7y<)Sra4~E2N$NlFoG{xQIrn(J;B)9zXxBLx+ zZ^m~W96C|xKb3JI$PLnScB~`flI+UBPcNH%$46Rc+}xi`!0pm+6ymnRjmT9bS)PPn zJYmvqVnUPM8@kbs`Ml4|ZoV^eavlbZMm&<>k6JeT4gCelB9*!=>du69x2MkJQ1*>$ zRf`0k!A6~ro_-fTNDZArm5=3vCftcHNR=tO>PuFUx?4WU{65w5Zw@>y4YS33h%&sM zn!J7CoR6_d059t6Sjpy1a;X&P?-G$yb@s+B%o=$Hu=cZLSW}Cc{qD@JmYFNO;=aSJ zpMqm4IwNUSZ}-c!hI>dcoi2?fk}73dYYJo?houKtKL~#229qvE>Nr{Q!}f{=&kptz zez;HSt&2|sn5z;x8aeK{I&ewOKr?{9;Mh36E}Y_Jc6}pt3s(m!uN{}BY1W5@N0_SY z3r}c`J9qFoR_!y!!L9=rv)_AKAMOlr?AD-O`clr@Homgyy`c|n?Q3L{$7(#`8pl06 z_gMzLQbk`YzAv>+;^kYj;p6c9Pow!=hg_d-jqd)m^WS1mv~#Nq*Y~7xlkbjrZjXEf zuX-NC6^Zl?^#@~n^L1#J#bIwN({~s(e1zhRO-ov8-N7f1O8if(w>eL-Xf53vfkC3l8AW#gP`Zs!K_gX>>_ zsgD0f;np8P6+vC+79s`8P@;8|b~o&#>ny?UmpTv7Ry!r0G#?yTfop8A@w>gnNc-(H zFFS31Q`(ek)X8JtP!rZ;^hdkCAXPb+3$|AIHEGxm+vwkGy)p@|BTPb_ zM-=xh*7GX;os)9<{!Wi_{&dGoNx`u$tqoLu?us=Gy1pTIHv4 z(~Qfa!pdiz8q4OTvYFKQa@AEogI#W;f@YUU0&v#^<1g~AYH~8~M};s|l@36}YlP<4 zwqLsU)phT)Tesf7vKHo#NM z!rzbtYQG?L?~@*3_22p7*^-%{MxQP2Cg#dl`W`uAg>Na`Waj{m&VkMlUoD2xn6qV( zBq3WfHDr9cT-#CjyLbI$u8MBbyY@o>kZ=Gj(w z_4domHF#$Q7j}+>e+z!MBI<|g-;w-v(=TN8Y-eM63gG*(Cl>C6`LibB9*+h*p5y)q(|AZ(V3uCOgPI7y zNBtG>RTkfsH03GM%@FnM4a}e67TFMf97?F74!ELFK|qGybO(aIiJ;E}iz#OGsCzS> zB!TMF?GP)D-^Qh>U8lvsBmD0mrqxREp&pq?IMgTU$# zn7TtQFPEpTeLiM#&G!%#4UKGz2L?}2NV(O>cE0wN;!j0%QD zTq=SjS5xRutUz%kTrxQUxwW2jfU+Ba=Y3?j9IXt@wk(Bdw2JGU5_vqhN;Z~G!m!x> zI0$7q8Fmxw;VEOl$X@shZBD-Sx1Cff9qW=xt)M9=@ z_|pPC8aF9)o4^J2Rt1=@9&#Hk>7`EP?%LLWx4Be7o9UxD-w0vQiOU)fK?H?~`4E^f zifFQ@VHG`E1$QuzNDhdF|LJyi49OavC>%B=16-ydP_ik*RXW|UuKxwPA7)wyU>0F? zaT6iTim5rf88RmcfNfth)!jAnNYGW57fcZ60i3?`w&zox!qr@Plb+fp1uTWz&gBPu z_pJ?LD^VYZkQBWY(d1_-QahZlsp{z zUepfrY*ZyFvhnwV@3AKQCT_>AhDS;psW7)hupSiaV=i$P9{{-3|7km&Ow16hte8BN zO^^eMJP7zWrN!pu5_r}zEJBmIqe+Pb?q1%)^xthMh++D!BB~k(tu}rTNSQnWa^ViP z5Ij6HQS~8_2ZEn{2-7-0$SL_fY*hmsqr9aktbqon`tpT=V%2=DuI%opHSO3G}?fPP=jsX1}`%(!9%$< zvUE6t(enbi@Mz+}Nw6BC2C!TP&lBTR1t)!hlEPF_3%%!E*?t%e9?X1)C@D-u)IHyd zPs5;JCh0JTNC(s_mHSvsEzM8BKC~$D^?6!R6IR z<{hkj(8CCFVxNJZHZm+&=GyS1H>HU^JZt@tmMMkmF+)OSc70!nk#?2vgJcfPND~ zuMg;*pajG|eq04UdA`fAl$>F%A;$&u(HM;wesdY_*_XJ8&%>40UtxFCL!e>y!Ll9< z&3O151+3qO#Kh_V7EA&O8} zO~8eXrLV^rtX3s~E-)kN2U@80EA1$Bw6WP0Spco=ZUMqLrMwn216D)yvCXAZDCq#| z(2ikefw)B<&9fz`z}*FygW&^6lCCK*V&orS1Cy@&{Sl6ODk@;XSHb4R=u2Z4Da1`& zARc9tNnW)G+e_HHj5tSbAS}0%yaAL^uM1+!&!1aC+E)q!LE66q@#=G@512vBE8HwJzujCIT zmoncBVp(uVVf_OyP^HKey&6FjOd8IqJrn8Gmd!iCR}DBDDOs)VbANZ`FraIpEeE<% zGFz<|kv+Yqnyt{{&ets17#QsSo1o|4f`L9DCk|C8lYWZ%Yul1WrchAQ?s!!|4b`<^`x6ANPWz0COK(z$sAPGTozgAL>&_ zZ=!Qulw8X3{&us7b0*M#LG5SMa<)YY%loh9JbMA4W|PghZO+;jrWDcb4r+#DB)pJ5 zj$AQ}^0AlTBXONCuc?(~BN3aXBqOz3%yUkI(kg%i9%k9IGC@G9e{Qg^mI2|PPvRbi zKiAC)G%I5!qhmfG6}$wj<7OaN?XPDm1QAozfdMhRW%)rB%vW(qNf0b5`Aj6xcLlAI zP3UWo3FSi?oVja|OiZtPaHpx$P<&uiP`DINne2#sIc-`Ug(qOF^r?Y~EEZ`rX54q3 zXd`qWCtfgplZe9O!EafE)UqXR3l%Nd?_sC>7ZU%C2=Z+ba$NDBp9rim@Jd`4)}W@X8l?RK`2{^pIKu68{zbY{1P<3w{=c+HtNp=4sw(AJYiTQ_-0QBd zmF<^PRl$mKT)~9%O61#d$nq`}Bd$7oB7w#ZOL3%sp6^2d?K?TzpVA$LeDfplu6}fs zr*jw{M!n(`AR%lxW#72cj6FK_5ahQQabS92I@fAn280BHLVthrkiXzMSe|^#nI6#1 zcY{a!f~KeqCNh^w#6v=5XKJ0o6UZft+GdZ5MFeCB@~(KmKExRJIuCU2vxK(!NYX%K5n!D{YBv4*Ij%~ARZ-mM>+%>`=A5Hl{lZy{x211Uos z?~YS_kbqn_1~;81)EfW@rcJKPr_x)9PBJbqoE}N3E<71t9GFsb7gW;uM1K z+l2>!H#jDr*rMZ}x7j$Vh3p=0%l5M|0owoRp-WqU_FrZuI_9^WhqwdWMl~n3`#Mn6 z5IlQ=8pXce5;Xq-X$kODtY zaACHJ_!i#`u@pKG*emLI`us67pk>tRpd|-I7=Z%_x9!Y6u`7NHM)*C>fvWHPmPKCB zZ14uh#|UqrrM-UFDl2ZjGzQ7MIxLUuc{Dz!KXV=3Shb+NF z&^@;BqV)I@7pTdC-`F~w+T|<0{V;oB}^H8)dJ`+R;D5*IGK-G+a<{C#FX@8Bpe z>i3}`Yo8x?FM(iorZStX-~fh{+5$hZlXiJ~6C}GJcs;#Syc#J!<4adpNO?eQp&u4e9_C19iJY{Bv?I0hZOxj#P9$=|;Y#Q3ze)&qYBKz)$)D(KNr@@oG|R6C zB#B%&14crtYZ?#GmN$D+7PgD!DZc;gAmc90NZ3DK0tXK4*7j*<_xn?846|k<#Fkrq z(Ec#6v*6OG4r|iMt9ol;71aPc{c}eqH;O;>da(KZW>Q{`B_8b`_A%hKH4F()3<%q1 z9(Jj4tBYA_>KnAPrveW(_MXTJXp3vT92&|hf+o}ZUdEPY+L2_hnqQ$#9-s!4*`vA+ z{2WvF9~uQW;VgP&z8xS{+gEZzj2HUrQY_L!j50jX;_f%SExAYZS9T9-K$6-?`U5Kh zAboEZ%)Rv?$7@)wb{+s$MTMa!S-biIu+Q}rSQ*B;V5e@E$fpw~=RI`-UB%b%2Y^n% z%%N@ZZG*=uN)34ffG&)62+%DJp^EQ(LGm2-3~BU{j=_Z@Z1+Hvy@_9u@q*IZy#5h? zbKj2(X#A&OD2T+GgmY-l0~DC|O(;{`j@QPQKQpQ}GjT3=wnQsJ?YfC?G0FJLN^-GL zuPTB!)bZ~*MSyPmr}VJwzJ?)fJ3I~1M@XlFo!|UZestkaiaj}l$wr}+0 zd_1SMZT5tii-#hiT-3%JQ}>>mo?_z}miRDtfLeu4mXJUHt`1ym^7ZdJEZw6j_IRyr z2nhs?xo^O!yX(`Bvp>faFzv=UZmv>35ed~$xp%_YzTd{~5>rrvw{xHbk24$V z-|UBxBBGck-oo*6cHr;T)%5b~q`Mw+b#guIYGJIZrH!V|E9~QJq}I2j*!>l_@N1ik z@G<1g|0Kq_?H9j3xB0K<05bgk9dQc3mR7gy%mLQNKYpwpZX!nUG>(a}h{iVXdQhTF19&tH4O~}U&3=GU$JzX3_D(1YseY*Q>qRjD+ z-+Ls>C#&srxsk9baMR?YM^5>9dj4UaBCD;vaYOQAPtBaj2+8dxkr5jvv=v29VC7Uy zRw>q*=Q-!-jN*Ck-y1#uTzPe~&GPp)=Woxs|MM*WjF}M7wQd!=#=)!v?G1uA7}l`= zYB+kpDS_z+PYvTeMt}9|8U^A%qW+zjwpjWG9}naCMz>?XIPWm`O=j98742uWBK(Q$ zuaMV_1spTtFP%@hyg|rw|6RCk*SkmSyKnAl=nko$@pj$pB@JtRm#_FIP<(yqhgiXl-LLrdvt)Vh z&FD#ASU_1riPtX=jxKoDr9a*+eFJ3 zm}cx_dwEba+sNt6!}l5)`y!9rom$g6U-8FrsR!aerFXA?U|HSqd-suqgPYH3xSza# zfm`+LE&p9z=jVRU@%(&_qx{6}Gv^On5_;adBmCocvkSbt3UDaS0>{A7hQ z!_L_S+9_Sv{wl@pezM|^=OaIk1eKJoZ#?Py>S9lbNWKy{&~@hee!iKDY!}a5|DJ!Q zlCvz+iNEhd-#ahMIKTVM_1`BGFTK4zNBd>v)BZoln>3%zI`=E^---XL4u59oJ##($ zuko1|KVN?kOb{(%cqG$c!LD$Qv7?wM<_@KK?x$Hq)$8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H11szF5 zK~#90?cKj_6jv0-@o(1HiICGkQXmLrNRtK=ifv4g(4Oypo3V66fg}gye_;cW>z>GGqc{gbI+N3e;?^oJJQ^_=Zw8GbLT9Gh=_=Y zh=@oo-m)G*rlx>-;1Vzgd<2{VX3NiafWLspz(ZgI_!ZcXK2izl1K>Ka1q_ZDe*#Ou zndqaI#@+{R0ez01_|fPCE5LhEk3c5&Ij~Lh#E-@c;7Y{fkRzG~z9D?#M`N|zmpF19 z_=Wb9Ka90$_Q@Z{&6bZNr*i-<`&DL*X=H0ptQ~=F&dR?UoU~(S=cksO!3y_Y*&iuftAHx>7mg z55_KVI^-m!D80j&A5gq9rbVHLNImhx_$<_fr6QNa4}j%BbC!gh5_yKSay&UQ;@ehFSBkX4xhiCp2#cJ6_ib?nbcnR#4@e+7cezpi)Wb(UK z-V(pge{aDq(9SDk|KRWoU(6mj-V=Y&Ns8CCb_48Zm*P7A#F+CGMa&*JT}u3@(-cMi zY=Zqfqqx26?haq99ync2{HPwqOB>fHp4o4fbJ#Cq8~DBa>}t6mcMo=A^}x9Ud`B9I zPe$w>I37ZYFIn}#QR1Dj?17`i8}rZuM~OG$r3a1@Z^Tm%93?(7Z#{67_{gqfpRf?I zPqFaxeAwwpo7WyVI^!ep+yh66k6;n|Br5TSe!xDNO1z;**e6wqH`Kp^sxyF~^P*Ej{R)A}eTH-r8Kv`$|FGpEbI%;`o@<}PBNOcjEm9+RzO z_ZBLI29~f-V2IG;;0b9#h_Hfv(o_hBPM}4h*}H;$qEraRRR z4y<9H92J70Y2sWNd`t13yj~A?Dpn7ilo~|D?4cAz%pN#hE`*ppluX3zfzzczh}lEQ zg_u2Xyca^O9!lBsRtOJ(pCasM72iKy7oE34h!Jb3RqX%dRtW3(UA}KBr$Sf>G-pZ5 zr4SZEO<1Z@FNAHJUm|*uav|IdI8Vt-u@L&e*^rZzgVcUFaw+Wej6}# z9h(ZExee<9RPa2uWO(hN^pr79aQy{nZXdX9J%~fxrWl(<;H`(!E)IWXJawLWDBWoJ zSmh92dMLd>i$cwK=%IA*m3lE?*+XeL!tqHBs~$@CfnyPlPsR|thtfUta;Q78dMGWU zGvBpXJ(R?;m_3xlv6wxS#-cTWHhRDX;4&}=oTEtWZ29>P#hX1(C?4r<01wf literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/24-0-locked.png b/theme/cfapishellext_custom_states/24-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..d380716890310e9110d006f113411d1a4e62dd11 GIT binary patch literal 325 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9E$svykh8Km+7C@5Lt z8c`CQpH@mmtT}V`<;yxP|+n%7sn8f&bL!+xeggfw8^_3 zJ(_L7Sz4r5!FkY`0Z!kmmA%D`_cT)DMo#;LBwiUz+nh?wOyL zKEXdFrnhIGo&nph{e=uNjp>53XE58c5^XX7J+lG6^;XfEEI4`h9FiStsdUpT7 z1=R#?h3b9J1;Y6)IBTB^Enqod{6cx*{TL=E#ti8%eHRX^c-%7WHTtn;UYeJKVrZ3v zw{-P^QxB|D5{}MeE$}n?c|(5M+FH&v|0J(4mO8L9eOd3sczTY{)Z)MOUm4Y(x?Q-l SN$D5RcMP7celF{r5}E)PfO{AK literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/24-1-shared.png b/theme/cfapishellext_custom_states/24-1-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..ebeea1710ae963ba45ad935161ff3e0b222e465b GIT binary patch literal 405 zcmV;G0c!qX7Fu*R|Fdd*yKbt z9O4{1;;YFU(`8(@)(_%*>N=)*D)OP7^kYD=sHpX8k|^@OXYI8`xir1RQ^x*V3HG0| zemD~$465RWcEY3Nfl3G$arV#S9cOqA#Ld?)NL-*<28kXS00000NkvXXu0mjfeW#?m literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/256-0-locked.png b/theme/cfapishellext_custom_states/256-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..4d24d7478e864b5808044dc0235b8a218a54e340 GIT binary patch literal 2257 zcmd6peKgyL8pnSTLTP2CoqF%ESr?>tMTSZ=rKGD+N@iX=YeN;Aw0w{xE7e7@&A|9sE)kLP(RBJrWS z%7kuOt`KEL>ygZTQcy*mPfjjq2yHEOe z$HWujukVGG#7|)}Z20@l_gch6jukXjUTQGEghhG%cAZoHX>8MjAFYB1>&ka0r+sX4 z&G$PO>BJ{5>n@vLqu|6V54N7Cc+?KlhXa+QQ5D*G>&KWckjk4x*=s+gL$w>O=Ff`q zYh=Gs{rwPdQg}wlUM}>z<2co&ed}_^cIvLSOy944+af#(n#qwV>4i>!4C>S{pw6h; zDZJwea3E|L+o)h1uMc?^L)?zF65Om4v2Ok3wWjeXyPS zH6E3mRpX!MB)6ADGg~2^Whtg0Hs`{|JE^G1)b*<}}Dv_+j?fMK%OHYhug&z$kvAn_akZt=IrO;^&K-T%og1j18eNv9BG|AEGRO8Niu~fd5?sW zW9tcM+6lo+B=k~$=Bw&eH+iE^66#H-CxBtF?aUd-+IgOjxT+6aebc(ZZ>Wq^3aDPkrJ>$gSuXdofJ->P053w}9? zXv&$w7Ox~KB7BIt4R%aY1pSd9l`&1EMG!c@!F1Mrm76!ZT9^Hd595nFm8sn%8nSuz zLaK$7^gCh9Nq)a!v*b}zX>2}5x;Egx?Rg;ofHb&GkMor}n3fusmg_CiLeCdhTuL;`<3{>)0wm-$i>=dj%5WCc1dLth2AAM-zdOXqoCElkYh zV~hVyW_TDenkcZ7{bhI*iqxa9qi;u^!U*`m=&dxjg$SNgYeL%oxkW&RFF2@`>B^6; z4xW+7>QqAB8qH>x1Yo*rJ(P=}JMCy-s8CFr1#%1(T*uI5QD>l%W}a zBqI6w?KuWwqc#ggIQp_~K3*`bxH38&@aDP6QFm%x0r$RLGyVP2{={aAAMrf@n2w+L zvy#NSZO4S4EK9*t?YVO=T;ge_ZSw+aaAr2E{R#^XSM@dIBzNYzDl}v)*PHxQ6K8)X zSVxOf3^o?VF)=nf66e-?&INfj>8ZRQ{>3<+eXBG%B29Usd^Hf#u+~lhwmy>z9k~77 zQ!(83A``U`@P4%iX-EgTgKGzo*6N&rQPe38Rh^2tmmOLr)_t=1#8;AiZ*vcN=^+W| zp67O8J`hLqw1ojMEsyNzJMQ$E-7}!uf;#G%bTxSoQt*gAi3s9qa=f9DhCq$05IVRP z*`#&^glx&)?JW&{zqoARlT|VLBizkq(cs_+k_br342IP8cL*Jutg_jQ6x@WQF(9Xk zSY*|8%MX!d#p~}zY=J%|Sq0x7Mk$ESFlLcS5vW~U#w75x$0s4>0wfi}GFXFMi^~N6 zD4v!dn0#8cGf0=W)c9#qY3opBowK2vc{!UwA0D4bvU*^4IJ!@Jydf>Zy8WHULLJne zs?G*m1u&Tbt2f_oatMez2>q)O&q|^lIMGfB^0(+m50#ky)qegKq5N%4{P`iTaZ~)a Z4D#~g0y!DXWN*JC04@X{%*Li%`X5U4nXLc- literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/256-1-shared.png b/theme/cfapishellext_custom_states/256-1-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..8dc623e4ebbee2c42390961597539c7b99dd7cdb GIT binary patch literal 3666 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 T9ipsY9t7~#=dCI&J(KMC@5Lt z8c`CQpH@mmtT}V`<;yxP|JzA+{!l4|sT}+M_)pmb~d?v{4TWqK=IpcWtyT;ppi_hC1o}hVN zciE34=2Cke3tq1ko1f4p_F8xIp*Q_&XC>=cbv?GZK5d5Bl>BEa!+uV^zr5Fc&tc7T zh5gqLY}v6=@q>Wa{s%AQ-|?J2;Fra*r*-LKzA~8`{ZdTvi7#E*t0ixwUH{Z<%W|zX zz3azeTb3$cE*`xNZ<~+wUx~i|Kz{<0CIlJmaCpXk^@eZr#B;iKKrbMC@5Lt z8c`CQpH@mmtT}V`<;yx0|O(6r;B4qMC;pWhI~y90~zS-Ah$4SG9WsyU4pi|5fK&x2E;&O zF-VYF+C=KK?(OL7~%MCvk$lVtq_#P-~eh5O80=f6BrFs79Y7zO4vsq5yS4o1g{CUB0o8J~N z^h16rqWf$187Fx+sOtQsAn#OLN&NXsoCIS@g0UpQSdw5YNidcq7)uh2B?-pT?v;NF hM?Lbm?orP__b1%wbm#3#@qt&nFB&!o{8@F^4QU+u6;|9OxIOy4{@*GrPZ;8~&$VW(xEG(7*MVuHj|n0XukzlZmj8SQpma8C=I>d`2H{(Hjm%t=yZ?;a&sSq2+dsj*GwyANZvO zYzu2TTvA-Y=8r*+OzV&?ir+8kdO;h`kz^xX*Y$JMi0-$K2 zj#(kjQBluvoLP!zbpLn>mh^!hlS8@M3UxjMjJZd}Z&Q0K0X!AM?~hUR4FCWD07*qoM6N<$ Eg7eu12><{9 literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/48-0-locked.png b/theme/cfapishellext_custom_states/48-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3e1f200e48a4effc04528fd6f474b50b02e428 GIT binary patch literal 459 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2sFA!!duPX5c3QCr^ zMwA5SrEalY(fIb7t~X1dMB78} zjVnASAA01z;Reg>e~irvNgvr~Z2u8xKI0AB>WeBJGg$v}Z#t?n%k`$hj@@mcADTD6 ze$!VRV`080)8c%!{h34wq0V^{CoJ>XvLg?X@+phX~Qp>t@ z3U0VPW3--eoWpHnL+#pXd8_i6DSuevn1rTFWEYo-T|IDfSJOV9XvRkNp8t>j%=s`| zXp1o>vAUNS?L0iv7hhxQIGxPqn&azB> zI>~rO;miHXq8+|k0<-t&8(ew#(`Z`V%&pfKduu$G3cuSH`sDlRJ8wg7aen(N>cyHg znd^ueL~)3M*-D3;pvD_Zm^ONXBvgcI&q>#OJ@a<4_J%}YL@;=|`njxgN@xNA{2#*B literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/48-1-shared.png b/theme/cfapishellext_custom_states/48-1-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..f58d721e4b157c37788b95c611f7156d1b6bb77e GIT binary patch literal 585 zcmV-P0=E5$P)h7N9PS!p5ZX$3b3uw0q^sHm!(9cB}Nx%H^3r#i#cPh#RZ^_sFCWxe8M$Z z%jrwWb=F#orXOgmwH8e|V!gF!;-TJ2Yf!;!qTs6$`Cv*O%etwonF3)+hF zTEib)I&h#k?@%j61_j6)T5&VpT1=}7BEwFaiAzo>B`PEC2U+C3dP5nP*4ARKTq*Po X9n*%T!#+I#00000NkvXXu0mjf%5(Wp literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/512-0-locked.png b/theme/cfapishellext_custom_states/512-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..4da18008d65357018238d5a552db4fe31d679e74 GIT binary patch literal 5037 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 literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/64-0-locked.png b/theme/cfapishellext_custom_states/64-0-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb27a958ad56bd089651ae780f998c814d5f690 GIT binary patch literal 580 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9GAd?3ttX4}GMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&3=E7HJzX3_D(1YsVVErvD01}U z{H_I@ffwBlDv2>36?GM`K6vktR>X~8rfc5lOb*l8a`{|-!ty#sQJv5LN7jQ4o!N#t zla=x}y+3=$}*{jNbmfDI36F#l{?^iDT=CtbFw*~5F z?^|3{aa1**a{b>anO&<{?`pmatIBuE{5wB9W4~60{i+LW?AfdZdq3=1)^NP_`DCuQ zX*HWZhexfuU>ASkU(mw4J(t$+SuA?1oAqrkSMHXd_6b%Wtl6&S7qb1ip2B#MZP7`l zMZ7MPSzV+%)VMp$1kUE=1um7o8MiomTkU?vNQW(r8Jik1m@i^e>nkRZt1wGP;g*95 gUWF4>JcUl$b4JT`iRDeJ0;T{4Pgg&ebxsLQ0P@=RHvj+t literal 0 HcmV?d00001 diff --git a/theme/cfapishellext_custom_states/64-1-shared.png b/theme/cfapishellext_custom_states/64-1-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..a840bd1bbe0cfff1d62e71f0b51baaba6b9590a1 GIT binary patch literal 656 zcmV;B0&o3^P)k8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10v$<2 zK~#90?V8U{!axv*zhFQg32(lE1Q1?H#J7;(RpW&R@Tg}{#FO!E@TL(DbQ4m-zwPXF z7WyS~Y1(0b(`~!^W1yg*pddafKpW@-Bj7uUQF5%6oU@lSfC(_$5N|*|r!fZM2)Jgm z@N{&6QV!!4$49)2P7dQ0rGdYA7d7=!3sGVIJZCMyJCVK^+hiB-;v$_qhL?ln)IAr> zrN%3AGhn1Vb`hq?6=0f9Zv7Fc0w;p`{seOcr3ySCcjsLUHAVOkO1O&~Jt@0!3JeG* z+(lDM(6)rWyQ%u;c-m|fzQ3K10yB;5jab7)`0OA z*NhRG_C5|w2;qyAnQv@*%)jl^B!t5xt?Me`s$_9sq81J^4osB7LC%4R zPPjwTfmsy;=F4A`+;U)dJdLCS6P0j>qyzJd;i(Qfj?l5fZ;0~3fj~G2BjoGAgeXLmJwkb!qNaL; z^0LLv^$6ur2$MZRnuRdkBc%1`=u(f6HA2sbo}QF!gepXIJwjFB2e|?)^$5j)q(%3Y zPHtv_u!hf&xnd~5vtX{L^nn-(a3`4SUNDy$QVx}=9-&>4aHvf52xTFQLzR1vkOaOC ql}L|}CDeO_ETYmQR8UZ`|NH|2NQBN$qd>O+0000