mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-09-22 13:01:05 +03:00
feat: add cross-platform file association management
Add an EE::System::FileAssociation abstraction with XDG, Windows, and macOS implementations. Expose extensions from syntax predefinitions and add custom widget support to UISettingsPanel. Add an ecode settings page for selecting file associations and installing an XDG desktop entry. Apply changes asynchronously with lifecycle-safe UI handling, disable controls while applying, and avoid redundant XDG database updates. Add translations and coverage for the new functionality. Implements SpartanJ/ecode#192
This commit is contained in:
@@ -1121,6 +1121,14 @@ Für sichtbare Änderung ecode neu starten.</string>
|
||||
<string name="enable_exclusive_mode_by_default">Exklusivmodus standardmäßig aktivieren</string>
|
||||
<string name="enable_exclusive_mode_by_default_tooltip">Deaktiviert globale Tastenkürzel in neu erstellten Terminals.</string>
|
||||
<string name="file_associations">Dateizuordnungen</string>
|
||||
<string name="apply">Anwenden</string>
|
||||
<string name="extension">Erweiterung</string>
|
||||
<string name="registered">Registriert</string>
|
||||
<string name="install_desktop_entry">Anwendungsstarter installieren</string>
|
||||
<string name="system_file_associations">System-Dateizuordnungen</string>
|
||||
<string name="system_file_associations_desc">Ecode für die ausgewählten Dateierweiterungen verfügbar machen. Das Betriebssystem kann weiterhin eine Bestätigung der Standardanwendung verlangen.</string>
|
||||
<string name="file_associations_updated">Dateizuordnungen aktualisiert.</string>
|
||||
<string name="file_associations_update_failed">Dateizuordnungen konnten nicht aktualisiert werden.</string>
|
||||
<string name="full">Voll</string>
|
||||
<string name="grayscale">Graustufen</string>
|
||||
<string name="hide_tabbar">Tableiste ausblenden</string>
|
||||
|
||||
@@ -1106,6 +1106,14 @@ Restart ecode to see the changes.</string>
|
||||
<string name="enable_exclusive_mode_by_default">Enable Exclusive Mode by Default</string>
|
||||
<string name="enable_exclusive_mode_by_default_tooltip">Disable global keybindings in newly created terminals.</string>
|
||||
<string name="file_associations">File Associations</string>
|
||||
<string name="apply">Apply</string>
|
||||
<string name="extension">Extension</string>
|
||||
<string name="registered">Registered</string>
|
||||
<string name="install_desktop_entry">Install application launcher</string>
|
||||
<string name="system_file_associations">System File Associations</string>
|
||||
<string name="system_file_associations_desc">Make ecode available for the selected file extensions. The operating system may still ask you to confirm the default application.</string>
|
||||
<string name="file_associations_updated">File associations updated.</string>
|
||||
<string name="file_associations_update_failed">Could not update file associations.</string>
|
||||
<string name="full">Full</string>
|
||||
<string name="grayscale">Grayscale</string>
|
||||
<string name="hide_tabbar">Hide Tab Bar</string>
|
||||
|
||||
@@ -1104,6 +1104,14 @@ Redémarrer ecode pour voir les changements.</string>
|
||||
<string name="enable_exclusive_mode_by_default">Activer le mode exclusif par défaut</string>
|
||||
<string name="enable_exclusive_mode_by_default_tooltip">Désactive les raccourcis globaux dans les nouveaux terminaux.</string>
|
||||
<string name="file_associations">Associations de fichiers</string>
|
||||
<string name="apply">Appliquer</string>
|
||||
<string name="extension">Extension</string>
|
||||
<string name="registered">Associé</string>
|
||||
<string name="install_desktop_entry">Installer le lanceur d’application</string>
|
||||
<string name="system_file_associations">Associations de fichiers système</string>
|
||||
<string name="system_file_associations_desc">Rendre ecode disponible pour les extensions sélectionnées. Le système d’exploitation peut encore demander de confirmer l’application par défaut.</string>
|
||||
<string name="file_associations_updated">Associations de fichiers mises à jour.</string>
|
||||
<string name="file_associations_update_failed">Impossible de mettre à jour les associations de fichiers.</string>
|
||||
<string name="full">Complet</string>
|
||||
<string name="grayscale">Niveaux de gris</string>
|
||||
<string name="hide_tabbar">Masquer la barre d'onglets</string>
|
||||
|
||||
@@ -890,6 +890,14 @@ file in the directory tree.</string>
|
||||
<string name="enable_exclusive_mode_by_default">默认启用独占模式</string>
|
||||
<string name="enable_exclusive_mode_by_default_tooltip">在新建终端中禁用全局快捷键。</string>
|
||||
<string name="file_associations">文件关联</string>
|
||||
<string name="apply">应用</string>
|
||||
<string name="extension">扩展名</string>
|
||||
<string name="registered">已注册</string>
|
||||
<string name="install_desktop_entry">安装应用程序启动器</string>
|
||||
<string name="system_file_associations">系统文件关联</string>
|
||||
<string name="system_file_associations_desc">让 ecode 可用于所选文件扩展名。操作系统可能仍会要求确认默认应用程序。</string>
|
||||
<string name="file_associations_updated">文件关联已更新。</string>
|
||||
<string name="file_associations_update_failed">无法更新文件关联。</string>
|
||||
<string name="folds_refresh_freq">折叠区域刷新频率</string>
|
||||
<string name="full">完全</string>
|
||||
<string name="grayscale">灰度</string>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <eepp/system/container.hpp>
|
||||
#include <eepp/system/cpu.hpp>
|
||||
#include <eepp/system/directorypack.hpp>
|
||||
#include <eepp/system/fileassociation.hpp>
|
||||
#include <eepp/system/fileinfo.hpp>
|
||||
#include <eepp/system/filemapped.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
|
||||
66
include/eepp/system/fileassociation.hpp
Normal file
66
include/eepp/system/fileassociation.hpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#ifndef EE_SYSTEM_FILEASSOCIATION_HPP
|
||||
#define EE_SYSTEM_FILEASSOCIATION_HPP
|
||||
|
||||
#include <eepp/config.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace EE::System {
|
||||
|
||||
struct EE_API FileAssociationApplication {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string executablePath;
|
||||
std::string iconPath;
|
||||
};
|
||||
|
||||
/** Registers a desktop application as a handler for filename extensions.
|
||||
*
|
||||
* Registrations are per-user. On Windows this adds the application to the Open With list, since
|
||||
* current Windows versions do not allow applications to silently change the user's default app.
|
||||
* XDG desktops use a generated desktop entry and the shared MIME database. macOS uses Launch
|
||||
* Services and therefore treats a selected extension as a request to make the application its
|
||||
* editor.
|
||||
*/
|
||||
class EE_API FileAssociation {
|
||||
public:
|
||||
explicit FileAssociation( FileAssociationApplication application );
|
||||
|
||||
static bool isSupported();
|
||||
|
||||
/** XDG platforms can additionally expose the generated desktop entry in application menus. */
|
||||
static bool supportsDesktopEntries();
|
||||
|
||||
/** Returns the extensions from @p supportedExtensions currently registered for this app. */
|
||||
std::vector<std::string>
|
||||
getRegisteredExtensions( const std::vector<std::string>& supportedExtensions ) const;
|
||||
|
||||
/** Makes @p registeredExtensions the registered subset of @p supportedExtensions. */
|
||||
bool setRegisteredExtensions( const std::vector<std::string>& registeredExtensions,
|
||||
const std::vector<std::string>& supportedExtensions );
|
||||
|
||||
/** Makes @p registeredExtensions the registered subset of @p supportedExtensions and updates
|
||||
* the desktop-entry visibility in the same operation. @p desktopEntryInstalled is ignored on
|
||||
* platforms that do not support desktop entries. */
|
||||
bool setRegisteredExtensions( const std::vector<std::string>& registeredExtensions,
|
||||
const std::vector<std::string>& supportedExtensions,
|
||||
bool desktopEntryInstalled );
|
||||
|
||||
bool isDesktopEntryInstalled() const;
|
||||
|
||||
bool setDesktopEntryInstalled( bool installed );
|
||||
|
||||
const std::string& getLastError() const { return mLastError; }
|
||||
|
||||
/** Returns a lower-case extension without a leading dot, or an empty string if invalid. */
|
||||
static std::string normalizeExtension( std::string_view extension );
|
||||
|
||||
private:
|
||||
FileAssociationApplication mApplication;
|
||||
mutable std::string mLastError;
|
||||
};
|
||||
|
||||
} // namespace EE::System
|
||||
|
||||
#endif
|
||||
@@ -82,6 +82,11 @@ class EE_API SyntaxDefinitionManager {
|
||||
|
||||
std::vector<std::string> getExtensionsPatternsSupported() const;
|
||||
|
||||
/** Returns the literal filename extensions declared by syntax definitions.
|
||||
* Filename-only patterns and patterns that cannot be represented as extensions are omitted.
|
||||
*/
|
||||
std::vector<std::string> getFileExtensions() const;
|
||||
|
||||
const SyntaxDefinition* getPtrByLSPName( const std::string& name ) const;
|
||||
|
||||
bool loadFromStream( IOStream& stream, std::vector<std::string>* addedLangs );
|
||||
|
||||
@@ -15,7 +15,8 @@ class xml_node;
|
||||
|
||||
namespace EE::UI {
|
||||
class UICheckBox;
|
||||
}
|
||||
class UIWidget;
|
||||
} // namespace EE::UI
|
||||
|
||||
namespace EE::UI::Tools {
|
||||
|
||||
@@ -79,9 +80,13 @@ struct EE_API ActionSetting {
|
||||
std::function<void()> action;
|
||||
};
|
||||
|
||||
struct EE_API CustomWidgetSetting {
|
||||
std::function<UIWidget*( UIWidget* parent )> create;
|
||||
};
|
||||
|
||||
using SettingValue =
|
||||
std::variant<BoolPointerSetting, BoolSetting, ChoiceSetting, EditableChoiceSetting,
|
||||
IntegerSetting, TextSetting, FloatSetting, ActionSetting>;
|
||||
IntegerSetting, TextSetting, FloatSetting, ActionSetting, CustomWidgetSetting>;
|
||||
|
||||
struct EE_API SettingDefinition {
|
||||
SettingDescriptor descriptor;
|
||||
@@ -165,6 +170,9 @@ class EE_API UISettingsPanel : public UILinearLayout {
|
||||
|
||||
bool addAction( SettingDescriptor descriptor, String buttonText, std::function<void()> action );
|
||||
|
||||
bool addCustomWidget( SettingDescriptor descriptor,
|
||||
std::function<UIWidget*( UIWidget* parent )> create );
|
||||
|
||||
void build();
|
||||
|
||||
void selectCategory( const std::string& category );
|
||||
|
||||
@@ -696,7 +696,7 @@ function generate_os_links()
|
||||
elseif os.is_real("mingw64") then
|
||||
multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } )
|
||||
elseif os.is_real("macosx") then
|
||||
multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } )
|
||||
multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreServices.framework", "CoreText.framework" } )
|
||||
elseif os.is_real("freebsd") then
|
||||
multiple_insert( os_links, { "rt", "pthread", "GL" } )
|
||||
elseif os.is_real("haiku") then
|
||||
|
||||
@@ -657,7 +657,7 @@ function generate_os_links()
|
||||
elseif os.istarget("mingw32") then
|
||||
multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } )
|
||||
elseif os.istarget("macosx") then
|
||||
multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } )
|
||||
multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreServices.framework", "CoreText.framework" } )
|
||||
elseif os.istarget("bsd") then
|
||||
multiple_insert( os_links, { "rt", "pthread", "GL" } )
|
||||
elseif os.istarget("haiku") then
|
||||
|
||||
790
src/eepp/system/fileassociation.cpp
Normal file
790
src/eepp/system/fileassociation.cpp
Normal file
@@ -0,0 +1,790 @@
|
||||
#include <eepp/system/fileassociation.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
#include <eepp/system/process.hpp>
|
||||
#include <eepp/system/sys.hpp>
|
||||
#include <iterator>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#if EE_PLATFORM == EE_PLATFORM_WIN
|
||||
#include <windows.h>
|
||||
|
||||
#include <shlobj.h>
|
||||
#elif EE_PLATFORM == EE_PLATFORM_MACOS
|
||||
#include <CoreServices/CoreServices.h>
|
||||
#endif
|
||||
|
||||
namespace EE::System {
|
||||
|
||||
namespace {
|
||||
|
||||
static std::vector<std::string> normalizeExtensions( const std::vector<std::string>& extensions ) {
|
||||
std::vector<std::string> normalized;
|
||||
normalized.reserve( extensions.size() );
|
||||
for ( const auto& extension : extensions ) {
|
||||
auto value = FileAssociation::normalizeExtension( extension );
|
||||
if ( !value.empty() )
|
||||
normalized.emplace_back( std::move( value ) );
|
||||
}
|
||||
std::sort( normalized.begin(), normalized.end() );
|
||||
normalized.erase( std::unique( normalized.begin(), normalized.end() ), normalized.end() );
|
||||
return normalized;
|
||||
}
|
||||
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
|
||||
static std::string applicationSlug( std::string_view id ) {
|
||||
std::string slug;
|
||||
slug.reserve( id.size() );
|
||||
for ( unsigned char character : id ) {
|
||||
if ( std::isalnum( character ) )
|
||||
slug += static_cast<char>( std::tolower( character ) );
|
||||
else if ( slug.empty() || slug.back() != '-' )
|
||||
slug += '-';
|
||||
}
|
||||
while ( !slug.empty() && slug.back() == '-' )
|
||||
slug.pop_back();
|
||||
return slug.empty() ? "application" : slug;
|
||||
}
|
||||
|
||||
static std::string xdgDataHome() {
|
||||
auto path = Sys::getEnv( "XDG_DATA_HOME" );
|
||||
if ( path.empty() ) {
|
||||
path = Sys::getUserDirectory();
|
||||
FileSystem::dirAddSlashAtEnd( path );
|
||||
path += ".local/share";
|
||||
}
|
||||
FileSystem::dirRemoveSlashAtEnd( path );
|
||||
return path;
|
||||
}
|
||||
|
||||
static std::string xdgConfigHome() {
|
||||
auto path = Sys::getEnv( "XDG_CONFIG_HOME" );
|
||||
if ( path.empty() ) {
|
||||
path = Sys::getUserDirectory();
|
||||
FileSystem::dirAddSlashAtEnd( path );
|
||||
path += ".config";
|
||||
}
|
||||
FileSystem::dirRemoveSlashAtEnd( path );
|
||||
return path;
|
||||
}
|
||||
|
||||
static std::string desktopEntryName( const FileAssociationApplication& application ) {
|
||||
return applicationSlug( application.id ) + ".desktop";
|
||||
}
|
||||
|
||||
static std::string desktopEntryPath( const FileAssociationApplication& application ) {
|
||||
return xdgDataHome() + "/applications/" + desktopEntryName( application );
|
||||
}
|
||||
|
||||
static std::string mimePackagePath( const FileAssociationApplication& application ) {
|
||||
return xdgDataHome() + "/mime/packages/" + applicationSlug( application.id ) + ".xml";
|
||||
}
|
||||
|
||||
static std::string desktopEscape( std::string_view value ) {
|
||||
std::string escaped;
|
||||
escaped.reserve( value.size() + 8 );
|
||||
for ( char character : value ) {
|
||||
switch ( character ) {
|
||||
case '\\':
|
||||
case '"':
|
||||
case '`':
|
||||
case '$':
|
||||
escaped += '\\';
|
||||
[[fallthrough]];
|
||||
default:
|
||||
escaped += character;
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
static std::string desktopExecPath( std::string_view path ) {
|
||||
if ( path.find_first_of( " \t\n\"'\\><~|&;$*?#()`" ) == std::string_view::npos )
|
||||
return std::string( path );
|
||||
auto environment = Sys::which( "env" );
|
||||
if ( environment.empty() )
|
||||
environment = "env";
|
||||
return environment + " \"" + desktopEscape( path ) + '"';
|
||||
}
|
||||
|
||||
static std::string xmlEscape( std::string_view value ) {
|
||||
std::string escaped;
|
||||
escaped.reserve( value.size() );
|
||||
for ( char character : value ) {
|
||||
switch ( character ) {
|
||||
case '&':
|
||||
escaped += "&";
|
||||
break;
|
||||
case '<':
|
||||
escaped += "<";
|
||||
break;
|
||||
case '>':
|
||||
escaped += ">";
|
||||
break;
|
||||
case '"':
|
||||
escaped += """;
|
||||
break;
|
||||
default:
|
||||
escaped += character;
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
static std::vector<std::string> desktopExtensions( const FileAssociationApplication& application ) {
|
||||
std::string contents;
|
||||
if ( !FileSystem::fileGet( desktopEntryPath( application ), contents ) )
|
||||
return {};
|
||||
constexpr std::string_view key = "X-EEPP-File-Extensions=";
|
||||
auto position = contents.find( key );
|
||||
if ( position == std::string::npos )
|
||||
return {};
|
||||
position += key.size();
|
||||
auto end = contents.find( '\n', position );
|
||||
auto values = String::split( contents.substr( position, end - position ), ';' );
|
||||
return normalizeExtensions( values );
|
||||
}
|
||||
|
||||
static bool desktopEntryVisible( const FileAssociationApplication& application ) {
|
||||
std::string contents;
|
||||
if ( !FileSystem::fileGet( desktopEntryPath( application ), contents ) )
|
||||
return false;
|
||||
return contents.find( "NoDisplay=false" ) != std::string::npos;
|
||||
}
|
||||
|
||||
static void loadMimeGlobs( const std::string& path, const std::unordered_set<std::string>& wanted,
|
||||
std::unordered_map<std::string, std::pair<int, std::string>>& types ) {
|
||||
std::string contents;
|
||||
if ( !FileSystem::fileGet( path, contents ) )
|
||||
return;
|
||||
for ( const auto& line : String::split( contents, '\n' ) ) {
|
||||
if ( line.empty() || line[0] == '#' )
|
||||
continue;
|
||||
auto fields = String::split( line, ':' );
|
||||
if ( fields.size() < 2 )
|
||||
continue;
|
||||
int weight = 50;
|
||||
std::string mime;
|
||||
std::string glob;
|
||||
if ( fields.size() >= 3 ) {
|
||||
Int32 parsedWeight = weight;
|
||||
String::fromString( parsedWeight, fields[0] );
|
||||
weight = parsedWeight;
|
||||
mime = fields[1];
|
||||
glob = fields[2];
|
||||
} else {
|
||||
mime = fields[0];
|
||||
glob = fields[1];
|
||||
}
|
||||
if ( !String::startsWith( glob, "*." ) )
|
||||
continue;
|
||||
auto extension = FileAssociation::normalizeExtension( glob.substr( 2 ) );
|
||||
if ( extension.empty() || !wanted.contains( extension ) )
|
||||
continue;
|
||||
auto found = types.find( extension );
|
||||
if ( found == types.end() || found->second.first < weight )
|
||||
types[extension] = { weight, std::move( mime ) };
|
||||
}
|
||||
}
|
||||
|
||||
static std::unordered_map<std::string, std::string>
|
||||
mimeTypesForExtensions( const std::vector<std::string>& extensions,
|
||||
const FileAssociationApplication& application ) {
|
||||
std::unordered_set<std::string> wanted( extensions.begin(), extensions.end() );
|
||||
std::unordered_map<std::string, std::pair<int, std::string>> weightedTypes;
|
||||
std::vector<std::string> dataDirectories{ xdgDataHome() };
|
||||
auto systemDirectories = Sys::getEnv( "XDG_DATA_DIRS" );
|
||||
if ( systemDirectories.empty() )
|
||||
systemDirectories = "/usr/local/share:/usr/share";
|
||||
for ( auto& directory : String::split( systemDirectories, ':' ) )
|
||||
dataDirectories.emplace_back( std::move( directory ) );
|
||||
for ( auto& directory : dataDirectories ) {
|
||||
FileSystem::dirRemoveSlashAtEnd( directory );
|
||||
loadMimeGlobs( directory + "/mime/globs2", wanted, weightedTypes );
|
||||
loadMimeGlobs( directory + "/mime/globs", wanted, weightedTypes );
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::string> types;
|
||||
types.reserve( extensions.size() );
|
||||
const auto slug = applicationSlug( application.id );
|
||||
for ( const auto& extension : extensions ) {
|
||||
auto found = weightedTypes.find( extension );
|
||||
types[extension] = found != weightedTypes.end()
|
||||
? std::move( found->second.second )
|
||||
: "application/x-" + slug + '-' + applicationSlug( extension );
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
static bool runDatabaseUpdate( const std::string& command, const std::string& directory ) {
|
||||
auto executable = Sys::which( command );
|
||||
if ( executable.empty() )
|
||||
return true;
|
||||
Process process;
|
||||
if ( !process.create( executable, std::vector<std::string>{ directory },
|
||||
Process::getDefaultOptions() ) )
|
||||
return false;
|
||||
int exitCode = -1;
|
||||
return process.join( &exitCode ) && exitCode == 0;
|
||||
}
|
||||
|
||||
static bool writeFileIfChanged( const std::string& path, const std::string& contents,
|
||||
bool& changed ) {
|
||||
std::string current;
|
||||
if ( FileSystem::fileExists( path ) ) {
|
||||
if ( !FileSystem::fileGet( path, current ) )
|
||||
return false;
|
||||
if ( current == contents )
|
||||
return true;
|
||||
}
|
||||
if ( !FileSystem::fileWrite( path, contents ) )
|
||||
return false;
|
||||
changed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool removeFileIfExists( const std::string& path, bool& changed ) {
|
||||
if ( !FileSystem::fileExists( path ) )
|
||||
return true;
|
||||
if ( !FileSystem::fileRemove( path ) )
|
||||
return false;
|
||||
changed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void updateMimeAppsSection( std::vector<std::string>& lines, std::string_view sectionName,
|
||||
const std::unordered_set<std::string>& selected,
|
||||
const std::unordered_set<std::string>& removed,
|
||||
std::string_view desktopName, bool addSelected ) {
|
||||
const std::string header = '[' + std::string( sectionName ) + ']';
|
||||
auto section = std::find( lines.begin(), lines.end(), header );
|
||||
if ( section == lines.end() ) {
|
||||
if ( !addSelected || selected.empty() )
|
||||
return;
|
||||
if ( !lines.empty() && !lines.back().empty() )
|
||||
lines.emplace_back();
|
||||
lines.emplace_back( header );
|
||||
section = std::prev( lines.end() );
|
||||
}
|
||||
|
||||
auto sectionEnd =
|
||||
std::find_if( std::next( section ), lines.end(), []( const std::string& line ) {
|
||||
return line.size() >= 2 && line.front() == '[' && line.back() == ']';
|
||||
} );
|
||||
std::unordered_set<std::string> found;
|
||||
for ( auto line = std::next( section ); line != sectionEnd; ) {
|
||||
const auto separator = line->find( '=' );
|
||||
if ( separator == std::string::npos ) {
|
||||
++line;
|
||||
continue;
|
||||
}
|
||||
const auto mime = line->substr( 0, separator );
|
||||
const bool isSelected = selected.contains( mime );
|
||||
if ( !isSelected && !removed.contains( mime ) ) {
|
||||
++line;
|
||||
continue;
|
||||
}
|
||||
found.emplace( mime );
|
||||
auto applications = String::split( line->substr( separator + 1 ), ';' );
|
||||
applications.erase( std::remove( applications.begin(), applications.end(), desktopName ),
|
||||
applications.end() );
|
||||
if ( addSelected && isSelected )
|
||||
applications.insert( applications.begin(), std::string( desktopName ) );
|
||||
if ( applications.empty() ) {
|
||||
line = lines.erase( line );
|
||||
sectionEnd = std::find_if( line, lines.end(), []( const std::string& value ) {
|
||||
return value.size() >= 2 && value.front() == '[' && value.back() == ']';
|
||||
} );
|
||||
continue;
|
||||
}
|
||||
*line = mime + '=';
|
||||
for ( const auto& application : applications )
|
||||
*line += application + ';';
|
||||
++line;
|
||||
}
|
||||
|
||||
if ( addSelected ) {
|
||||
std::vector<std::string> missing;
|
||||
for ( const auto& mime : selected ) {
|
||||
if ( !found.contains( mime ) )
|
||||
missing.emplace_back( mime );
|
||||
}
|
||||
std::sort( missing.begin(), missing.end() );
|
||||
for ( const auto& mime : missing ) {
|
||||
sectionEnd = lines.insert( sectionEnd, mime + '=' + std::string( desktopName ) + ';' );
|
||||
++sectionEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool updateMimeAppsFile( const std::string& path,
|
||||
const std::unordered_set<std::string>& selected,
|
||||
const std::unordered_set<std::string>& removed,
|
||||
std::string_view desktopName, std::string& error ) {
|
||||
std::string contents;
|
||||
if ( FileSystem::fileExists( path ) && !FileSystem::fileGet( path, contents ) ) {
|
||||
error = "Could not read the XDG MIME application preferences.";
|
||||
return false;
|
||||
}
|
||||
auto lines = String::split( contents, '\n', true );
|
||||
if ( lines.size() == 1 && lines.front().empty() )
|
||||
lines.clear();
|
||||
updateMimeAppsSection( lines, "Default Applications", selected, removed, desktopName, true );
|
||||
updateMimeAppsSection( lines, "Added Associations", selected, removed, desktopName, true );
|
||||
updateMimeAppsSection( lines, "Removed Associations", selected, removed, desktopName, false );
|
||||
std::string updated;
|
||||
for ( const auto& line : lines )
|
||||
updated += line + '\n';
|
||||
if ( updated == contents )
|
||||
return true;
|
||||
if ( !FileSystem::fileWrite( path, updated ) ) {
|
||||
error = "Could not write the XDG MIME application preferences.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool updateMimeAppsRegistration( const FileAssociationApplication& application,
|
||||
const std::unordered_set<std::string>& selected,
|
||||
const std::unordered_set<std::string>& removed,
|
||||
std::string& error ) {
|
||||
const auto configHome = xdgConfigHome();
|
||||
if ( !FileSystem::isDirectory( configHome ) && !FileSystem::makeDir( configHome, true ) ) {
|
||||
error = "Could not create the XDG configuration directory.";
|
||||
return false;
|
||||
}
|
||||
std::vector<std::string> paths{ configHome + "/mimeapps.list" };
|
||||
for ( auto desktop : String::split( Sys::getEnv( "XDG_CURRENT_DESKTOP" ), ':' ) ) {
|
||||
String::toLowerInPlace( desktop );
|
||||
if ( !desktop.empty() )
|
||||
paths.emplace_back( configHome + '/' + desktop + "-mimeapps.list" );
|
||||
}
|
||||
const auto legacyPath = xdgDataHome() + "/applications/mimeapps.list";
|
||||
if ( FileSystem::fileExists( legacyPath ) )
|
||||
paths.emplace_back( legacyPath );
|
||||
std::sort( paths.begin(), paths.end() );
|
||||
paths.erase( std::unique( paths.begin(), paths.end() ), paths.end() );
|
||||
for ( const auto& path : paths ) {
|
||||
if ( !updateMimeAppsFile( path, selected, removed, desktopEntryName( application ),
|
||||
error ) )
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool writeXdgRegistration( const FileAssociationApplication& application,
|
||||
const std::vector<std::string>& extensions, bool visible,
|
||||
std::string& error ) {
|
||||
const auto previousExtensions = desktopExtensions( application );
|
||||
const auto dataHome = xdgDataHome();
|
||||
const auto applicationsDirectory = dataHome + "/applications";
|
||||
const auto mimeDirectory = dataHome + "/mime";
|
||||
const auto packageDirectory = mimeDirectory + "/packages";
|
||||
if ( ( !FileSystem::isDirectory( applicationsDirectory ) &&
|
||||
!FileSystem::makeDir( applicationsDirectory, true ) ) ||
|
||||
( !FileSystem::isDirectory( packageDirectory ) &&
|
||||
!FileSystem::makeDir( packageDirectory, true ) ) ) {
|
||||
error = "Could not create the XDG application directories.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto previousMimeTypes = mimeTypesForExtensions( previousExtensions, application );
|
||||
const auto mimeTypes = mimeTypesForExtensions( extensions, application );
|
||||
std::unordered_set<std::string> uniqueMimeTypes;
|
||||
std::string package =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n";
|
||||
const auto slug = applicationSlug( application.id );
|
||||
bool hasCustomMimeTypes = false;
|
||||
for ( const auto& extension : extensions ) {
|
||||
const auto& mime = mimeTypes.at( extension );
|
||||
uniqueMimeTypes.insert( mime );
|
||||
if ( !String::startsWith( mime, "application/x-" + slug + '-' ) )
|
||||
continue;
|
||||
hasCustomMimeTypes = true;
|
||||
package += " <mime-type type=\"" + mime + "\">\n";
|
||||
package += " <comment>" + xmlEscape( application.name ) + " " + extension +
|
||||
" document</comment>\n";
|
||||
package += " <glob pattern=\"*." + extension + "\"/>\n";
|
||||
package += " </mime-type>\n";
|
||||
}
|
||||
package += "</mime-info>\n";
|
||||
const auto packagePath = mimePackagePath( application );
|
||||
bool mimeDatabaseChanged = false;
|
||||
if ( hasCustomMimeTypes ) {
|
||||
if ( !writeFileIfChanged( packagePath, package, mimeDatabaseChanged ) ) {
|
||||
error = "Could not write the XDG MIME package.";
|
||||
return false;
|
||||
}
|
||||
} else if ( !removeFileIfExists( packagePath, mimeDatabaseChanged ) ) {
|
||||
error = "Could not remove the obsolete XDG MIME package.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto desktopPath = desktopEntryPath( application );
|
||||
bool desktopDatabaseChanged = false;
|
||||
if ( extensions.empty() && !visible ) {
|
||||
if ( !removeFileIfExists( desktopPath, desktopDatabaseChanged ) ) {
|
||||
error = "Could not remove the XDG desktop entry.";
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
std::vector<std::string> sortedMimeTypes( uniqueMimeTypes.begin(), uniqueMimeTypes.end() );
|
||||
std::sort( sortedMimeTypes.begin(), sortedMimeTypes.end() );
|
||||
std::string desktop = "[Desktop Entry]\nType=Application\nVersion=1.0\n";
|
||||
desktop += "Name=" + desktopEscape( application.name ) + "\n";
|
||||
desktop += "Comment=Edit source code and text files\n";
|
||||
desktop += "Exec=" + desktopExecPath( application.executablePath ) + " %F\n";
|
||||
desktop += "TryExec=" + application.executablePath + "\n";
|
||||
if ( !application.iconPath.empty() )
|
||||
desktop += "Icon=" + application.iconPath + "\n";
|
||||
desktop += "Terminal=false\nCategories=Development;TextEditor;\n";
|
||||
desktop += std::string( "NoDisplay=" ) + ( visible ? "false\n" : "true\n" );
|
||||
desktop += "MimeType=";
|
||||
for ( const auto& mime : sortedMimeTypes )
|
||||
desktop += mime + ';';
|
||||
desktop += "\nX-EEPP-File-Extensions=";
|
||||
for ( const auto& extension : extensions )
|
||||
desktop += extension + ';';
|
||||
desktop += '\n';
|
||||
if ( !writeFileIfChanged( desktopPath, desktop, desktopDatabaseChanged ) ) {
|
||||
error = "Could not write the XDG desktop entry.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ( mimeDatabaseChanged && !runDatabaseUpdate( "update-mime-database", mimeDirectory ) ) ||
|
||||
( desktopDatabaseChanged &&
|
||||
!runDatabaseUpdate( "update-desktop-database", applicationsDirectory ) ) ) {
|
||||
error = "The XDG registration was written, but a desktop database update failed.";
|
||||
return false;
|
||||
}
|
||||
std::unordered_set<std::string> selectedMimeTypes;
|
||||
for ( const auto& [extension, mime] : mimeTypes )
|
||||
selectedMimeTypes.emplace( mime );
|
||||
std::unordered_set<std::string> removedMimeTypes;
|
||||
for ( const auto& [extension, mime] : previousMimeTypes ) {
|
||||
if ( !selectedMimeTypes.contains( mime ) )
|
||||
removedMimeTypes.emplace( mime );
|
||||
}
|
||||
if ( !updateMimeAppsRegistration( application, selectedMimeTypes, removedMimeTypes, error ) )
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
#elif EE_PLATFORM == EE_PLATFORM_WIN
|
||||
|
||||
static std::wstring toWide( std::string_view value ) {
|
||||
return String::fromUtf8( std::string( value ) ).toWideString();
|
||||
}
|
||||
|
||||
static bool setRegistryString( HKEY root, const std::wstring& key, const wchar_t* valueName,
|
||||
const std::wstring& value, std::string& error ) {
|
||||
HKEY handle = nullptr;
|
||||
auto status = RegCreateKeyExW( root, key.c_str(), 0, nullptr, 0, KEY_SET_VALUE, nullptr,
|
||||
&handle, nullptr );
|
||||
if ( status != ERROR_SUCCESS ) {
|
||||
error = "Could not create a file-association registry key (error " +
|
||||
std::to_string( status ) + ").";
|
||||
return false;
|
||||
}
|
||||
status = RegSetValueExW( handle, valueName, 0, REG_SZ,
|
||||
reinterpret_cast<const BYTE*>( value.c_str() ),
|
||||
static_cast<DWORD>( ( value.size() + 1 ) * sizeof( wchar_t ) ) );
|
||||
RegCloseKey( handle );
|
||||
if ( status != ERROR_SUCCESS ) {
|
||||
error = "Could not write a file-association registry value (error " +
|
||||
std::to_string( status ) + ").";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool registryValueExists( const std::wstring& key, const std::wstring& valueName ) {
|
||||
HKEY handle = nullptr;
|
||||
if ( RegOpenKeyExW( HKEY_CURRENT_USER, key.c_str(), 0, KEY_QUERY_VALUE, &handle ) !=
|
||||
ERROR_SUCCESS )
|
||||
return false;
|
||||
const auto status =
|
||||
RegQueryValueExW( handle, valueName.c_str(), nullptr, nullptr, nullptr, nullptr );
|
||||
RegCloseKey( handle );
|
||||
return status == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
static bool deleteRegistryValue( const std::wstring& key, const std::wstring& valueName,
|
||||
std::string& error ) {
|
||||
HKEY handle = nullptr;
|
||||
if ( RegOpenKeyExW( HKEY_CURRENT_USER, key.c_str(), 0, KEY_SET_VALUE, &handle ) !=
|
||||
ERROR_SUCCESS )
|
||||
return true;
|
||||
const auto status = RegDeleteValueW( handle, valueName.c_str() );
|
||||
RegCloseKey( handle );
|
||||
if ( status != ERROR_SUCCESS && status != ERROR_FILE_NOT_FOUND ) {
|
||||
error = "Could not remove a file-association registry value (error " +
|
||||
std::to_string( status ) + ").";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#elif EE_PLATFORM == EE_PLATFORM_MACOS
|
||||
|
||||
class CFRef {
|
||||
public:
|
||||
explicit CFRef( CFTypeRef value = nullptr ) : mValue( value ) {}
|
||||
~CFRef() {
|
||||
if ( mValue )
|
||||
CFRelease( mValue );
|
||||
}
|
||||
CFRef( const CFRef& ) = delete;
|
||||
CFRef& operator=( const CFRef& ) = delete;
|
||||
CFTypeRef get() const { return mValue; }
|
||||
|
||||
private:
|
||||
CFTypeRef mValue;
|
||||
};
|
||||
|
||||
static CFStringRef cfString( const std::string& value ) {
|
||||
return CFStringCreateWithCString( kCFAllocatorDefault, value.c_str(), kCFStringEncodingUTF8 );
|
||||
}
|
||||
|
||||
static CFStringRef typeForExtension( const std::string& extension ) {
|
||||
CFRef extensionString( cfString( extension ) );
|
||||
if ( !extensionString.get() )
|
||||
return nullptr;
|
||||
return UTTypeCreatePreferredIdentifierForTag(
|
||||
kUTTagClassFilenameExtension, static_cast<CFStringRef>( extensionString.get() ), nullptr );
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
FileAssociation::FileAssociation( FileAssociationApplication application ) :
|
||||
mApplication( std::move( application ) ) {}
|
||||
|
||||
bool FileAssociation::isSupported() {
|
||||
#if EE_PLATFORM == EE_PLATFORM_WIN || EE_PLATFORM == EE_PLATFORM_MACOS || \
|
||||
EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool FileAssociation::supportsDesktopEntries() {
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string FileAssociation::normalizeExtension( std::string_view extension ) {
|
||||
while ( !extension.empty() && extension.front() == '.' )
|
||||
extension.remove_prefix( 1 );
|
||||
if ( extension.empty() || extension.size() > 64 )
|
||||
return {};
|
||||
std::string normalized;
|
||||
normalized.reserve( extension.size() );
|
||||
for ( unsigned char character : extension ) {
|
||||
if ( !std::isalnum( character ) && character != '_' && character != '-' &&
|
||||
character != '+' && character != '.' )
|
||||
return {};
|
||||
normalized += static_cast<char>( std::tolower( character ) );
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::string> FileAssociation::getRegisteredExtensions(
|
||||
const std::vector<std::string>& supportedExtensions ) const {
|
||||
mLastError.clear();
|
||||
const auto supported = normalizeExtensions( supportedExtensions );
|
||||
std::vector<std::string> registered;
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
const auto current = desktopExtensions( mApplication );
|
||||
std::set_intersection( supported.begin(), supported.end(), current.begin(), current.end(),
|
||||
std::back_inserter( registered ) );
|
||||
#elif EE_PLATFORM == EE_PLATFORM_WIN
|
||||
const auto progId = toWide( mApplication.id );
|
||||
for ( const auto& extension : supported ) {
|
||||
if ( registryValueExists(
|
||||
L"Software\\Classes\\." + toWide( extension ) + L"\\OpenWithProgids", progId ) )
|
||||
registered.emplace_back( extension );
|
||||
}
|
||||
#elif EE_PLATFORM == EE_PLATFORM_MACOS
|
||||
CFRef applicationId( cfString( mApplication.id ) );
|
||||
if ( !applicationId.get() ) {
|
||||
mLastError = "The application identifier is not valid UTF-8.";
|
||||
return {};
|
||||
}
|
||||
for ( const auto& extension : supported ) {
|
||||
CFRef type( typeForExtension( extension ) );
|
||||
if ( !type.get() )
|
||||
continue;
|
||||
CFRef handler( LSCopyDefaultRoleHandlerForContentType(
|
||||
static_cast<CFStringRef>( type.get() ), kLSRolesEditor | kLSRolesViewer ) );
|
||||
if ( handler.get() && CFEqual( handler.get(), applicationId.get() ) )
|
||||
registered.emplace_back( extension );
|
||||
}
|
||||
#else
|
||||
(void)supported;
|
||||
mLastError = "File associations are not supported on this platform.";
|
||||
#endif
|
||||
return registered;
|
||||
}
|
||||
|
||||
bool FileAssociation::setRegisteredExtensions(
|
||||
const std::vector<std::string>& registeredExtensions,
|
||||
const std::vector<std::string>& supportedExtensions ) {
|
||||
return setRegisteredExtensions( registeredExtensions, supportedExtensions,
|
||||
supportsDesktopEntries() ? isDesktopEntryInstalled() : false );
|
||||
}
|
||||
|
||||
bool FileAssociation::setRegisteredExtensions( const std::vector<std::string>& registeredExtensions,
|
||||
const std::vector<std::string>& supportedExtensions,
|
||||
bool desktopEntryInstalled ) {
|
||||
mLastError.clear();
|
||||
const auto supported = normalizeExtensions( supportedExtensions );
|
||||
const auto requested = normalizeExtensions( registeredExtensions );
|
||||
std::vector<std::string> selected;
|
||||
selected.reserve( std::min( requested.size(), supported.size() ) );
|
||||
std::set_intersection( requested.begin(), requested.end(), supported.begin(), supported.end(),
|
||||
std::back_inserter( selected ) );
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
return writeXdgRegistration( mApplication, selected, desktopEntryInstalled, mLastError );
|
||||
#elif EE_PLATFORM == EE_PLATFORM_WIN
|
||||
(void)desktopEntryInstalled;
|
||||
const auto progId = toWide( mApplication.id );
|
||||
const auto executable = toWide( mApplication.executablePath );
|
||||
const auto executableName =
|
||||
toWide( FileSystem::fileNameFromPath( mApplication.executablePath ) );
|
||||
const auto classes = std::wstring( L"Software\\Classes\\" );
|
||||
const auto applicationKey = classes + L"Applications\\" + executableName;
|
||||
const auto command = L"\"" + executable + L"\" \"%1\"";
|
||||
if ( !setRegistryString( HKEY_CURRENT_USER, classes + progId, nullptr,
|
||||
toWide( mApplication.name + " document" ), mLastError ) ||
|
||||
!setRegistryString( HKEY_CURRENT_USER, applicationKey, L"FriendlyAppName",
|
||||
toWide( mApplication.name ), mLastError ) ||
|
||||
!setRegistryString( HKEY_CURRENT_USER, classes + progId + L"\\shell\\open\\command",
|
||||
nullptr, command, mLastError ) ||
|
||||
!setRegistryString( HKEY_CURRENT_USER, applicationKey + L"\\shell\\open\\command", nullptr,
|
||||
command, mLastError ) )
|
||||
return false;
|
||||
if ( !mApplication.iconPath.empty() &&
|
||||
( !setRegistryString( HKEY_CURRENT_USER, classes + progId + L"\\DefaultIcon", nullptr,
|
||||
toWide( mApplication.iconPath ), mLastError ) ||
|
||||
!setRegistryString( HKEY_CURRENT_USER, applicationKey + L"\\DefaultIcon", nullptr,
|
||||
toWide( mApplication.iconPath ), mLastError ) ) )
|
||||
return false;
|
||||
for ( const auto& extension : supported ) {
|
||||
const bool shouldRegister =
|
||||
std::binary_search( selected.begin(), selected.end(), extension );
|
||||
const auto dotExtension = toWide( '.' + extension );
|
||||
const auto openWithKey = classes + dotExtension + L"\\OpenWithProgids";
|
||||
const auto supportedTypesKey = applicationKey + L"\\SupportedTypes";
|
||||
if ( shouldRegister ) {
|
||||
if ( !setRegistryString( HKEY_CURRENT_USER, openWithKey, progId.c_str(), L"",
|
||||
mLastError ) ||
|
||||
!setRegistryString( HKEY_CURRENT_USER, supportedTypesKey, dotExtension.c_str(),
|
||||
L"", mLastError ) )
|
||||
return false;
|
||||
} else if ( !deleteRegistryValue( openWithKey, progId, mLastError ) ||
|
||||
!deleteRegistryValue( supportedTypesKey, dotExtension, mLastError ) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SHChangeNotify( SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr );
|
||||
return true;
|
||||
#elif EE_PLATFORM == EE_PLATFORM_MACOS
|
||||
(void)desktopEntryInstalled;
|
||||
CFRef bundleUrl( CFBundleCopyBundleURL( CFBundleGetMainBundle() ) );
|
||||
if ( !bundleUrl.get() ||
|
||||
LSRegisterURL( static_cast<CFURLRef>( bundleUrl.get() ), true ) != noErr ) {
|
||||
mLastError = "Launch Services could not register the application bundle.";
|
||||
return false;
|
||||
}
|
||||
CFRef applicationId( cfString( mApplication.id ) );
|
||||
if ( !applicationId.get() ) {
|
||||
mLastError = "The application identifier is not valid UTF-8.";
|
||||
return false;
|
||||
}
|
||||
for ( const auto& extension : supported ) {
|
||||
CFRef type( typeForExtension( extension ) );
|
||||
if ( !type.get() )
|
||||
continue;
|
||||
const bool shouldRegister =
|
||||
std::binary_search( selected.begin(), selected.end(), extension );
|
||||
if ( shouldRegister ) {
|
||||
const auto status = LSSetDefaultRoleHandlerForContentType(
|
||||
static_cast<CFStringRef>( type.get() ), kLSRolesEditor | kLSRolesViewer,
|
||||
static_cast<CFStringRef>( applicationId.get() ) );
|
||||
if ( status != noErr ) {
|
||||
mLastError = "Launch Services could not set a file association (error " +
|
||||
std::to_string( status ) + ").";
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
CFRef current( LSCopyDefaultRoleHandlerForContentType(
|
||||
static_cast<CFStringRef>( type.get() ), kLSRolesEditor | kLSRolesViewer ) );
|
||||
if ( !current.get() || !CFEqual( current.get(), applicationId.get() ) )
|
||||
continue;
|
||||
CFRef handlers( LSCopyAllRoleHandlersForContentType( static_cast<CFStringRef>( type.get() ),
|
||||
kLSRolesEditor | kLSRolesViewer ) );
|
||||
if ( !handlers.get() )
|
||||
continue;
|
||||
auto array = static_cast<CFArrayRef>( handlers.get() );
|
||||
bool reassigned = false;
|
||||
for ( CFIndex index = 0; index < CFArrayGetCount( array ); ++index ) {
|
||||
auto handler = static_cast<CFStringRef>( CFArrayGetValueAtIndex( array, index ) );
|
||||
if ( CFEqual( handler, applicationId.get() ) )
|
||||
continue;
|
||||
if ( LSSetDefaultRoleHandlerForContentType( static_cast<CFStringRef>( type.get() ),
|
||||
kLSRolesEditor | kLSRolesViewer,
|
||||
handler ) == noErr ) {
|
||||
reassigned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( !reassigned ) {
|
||||
mLastError = "Launch Services has no alternative handler for ." + extension + ".";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
(void)selected;
|
||||
(void)desktopEntryInstalled;
|
||||
(void)supported;
|
||||
mLastError = "File associations are not supported on this platform.";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool FileAssociation::isDesktopEntryInstalled() const {
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
return desktopEntryVisible( mApplication );
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool FileAssociation::setDesktopEntryInstalled( bool installed ) {
|
||||
mLastError.clear();
|
||||
#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
|
||||
return writeXdgRegistration( mApplication, desktopExtensions( mApplication ), installed,
|
||||
mLastError );
|
||||
#else
|
||||
(void)installed;
|
||||
mLastError = "Desktop entry installation is not supported on this platform.";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace EE::System
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <eepp/ui/doc/languages/xml.hpp>
|
||||
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <unordered_set>
|
||||
|
||||
@@ -706,6 +707,60 @@ std::vector<std::string> SyntaxDefinitionManager::getExtensionsPatternsSupported
|
||||
return vexts;
|
||||
}
|
||||
|
||||
static std::vector<std::string> fileExtensionsFromPattern( std::string_view pattern ) {
|
||||
if ( pattern.size() < 4 || !pattern.starts_with( "%." ) || pattern.back() != '$' )
|
||||
return {};
|
||||
pattern.remove_prefix( 2 );
|
||||
pattern.remove_suffix( 1 );
|
||||
std::vector<std::string> extensions( 1 );
|
||||
for ( size_t i = 0; i < pattern.size(); ++i ) {
|
||||
char character = pattern[i];
|
||||
if ( character == '%' ) {
|
||||
if ( ++i >= pattern.size() || std::isalnum( static_cast<unsigned char>( pattern[i] ) ) )
|
||||
return {};
|
||||
character = pattern[i];
|
||||
} else if ( !std::isalnum( static_cast<unsigned char>( character ) ) && character != '_' ) {
|
||||
return {};
|
||||
}
|
||||
for ( auto& extension : extensions )
|
||||
extension += character;
|
||||
if ( i + 1 < pattern.size() && pattern[i + 1] == '?' ) {
|
||||
const auto variants = extensions.size();
|
||||
for ( size_t variant = 0; variant < variants; ++variant ) {
|
||||
auto withoutOptional = extensions[variant];
|
||||
withoutOptional.pop_back();
|
||||
extensions.emplace_back( std::move( withoutOptional ) );
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
std::vector<std::string> SyntaxDefinitionManager::getFileExtensions() const {
|
||||
Lock l( mMutex );
|
||||
std::unordered_set<std::string> extensions;
|
||||
auto addPatterns = [&extensions]( const auto& definition ) {
|
||||
for ( const auto& pattern : definition.getFiles() ) {
|
||||
for ( auto& extension : fileExtensionsFromPattern( pattern ) ) {
|
||||
String::toLowerInPlace( extension );
|
||||
extensions.emplace( std::move( extension ) );
|
||||
}
|
||||
}
|
||||
};
|
||||
for ( const auto& definition : mDefinitions )
|
||||
addPatterns( *definition );
|
||||
for ( const auto& definition : mPreDefinitions )
|
||||
addPatterns( definition );
|
||||
|
||||
std::vector<std::string> sortedExtensions;
|
||||
sortedExtensions.reserve( extensions.size() );
|
||||
for ( auto& extension : extensions )
|
||||
sortedExtensions.emplace_back( std::move( extension ) );
|
||||
std::sort( sortedExtensions.begin(), sortedExtensions.end() );
|
||||
return sortedExtensions;
|
||||
}
|
||||
|
||||
const SyntaxDefinition* SyntaxDefinitionManager::getPtrByLSPName( const std::string& name ) const {
|
||||
Lock l( mMutex );
|
||||
for ( const auto& definition : mDefinitions ) {
|
||||
|
||||
@@ -308,6 +308,15 @@ static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout(
|
||||
R"xml(<TextInput id="setting_control_widget" class="settings_text" />)xml" ) );
|
||||
static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout(
|
||||
R"xml(<PushButton id="setting_control_widget" class="settings_action" />)xml" ) );
|
||||
static const SettingsLayoutTemplate SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT( R"xml(
|
||||
<vbox lw="mp" lh="wc" class="settings_option">
|
||||
<vbox id="setting_info" lw="mp" lh="wc">
|
||||
<TextView id="setting_name" lw="mp" lh="wc" class="settings_option_name" focusable="false" />
|
||||
<TextView id="setting_description" lw="mp" lh="wc" class="settings_option_description" focusable="false" />
|
||||
</vbox>
|
||||
<vbox id="setting_custom_widget" lw="mp" lh="wc" margin-top="8dp" />
|
||||
</vbox>
|
||||
)xml" );
|
||||
|
||||
static void disableTabFocusTree( Node* node ) {
|
||||
if ( node->isWidget() )
|
||||
@@ -501,6 +510,13 @@ bool UISettingsPanel::addAction( SettingDescriptor descriptor, String buttonText
|
||||
ActionSetting{ std::move( buttonText ), std::move( action ) } } );
|
||||
}
|
||||
|
||||
bool UISettingsPanel::addCustomWidget( SettingDescriptor descriptor,
|
||||
std::function<UIWidget*( UIWidget* parent )> create ) {
|
||||
return !mImpl->built && create &&
|
||||
mImpl->model.addSetting(
|
||||
{ std::move( descriptor ), CustomWidgetSetting{ std::move( create ) } } );
|
||||
}
|
||||
|
||||
void UISettingsPanel::build() {
|
||||
if ( mImpl->built )
|
||||
return;
|
||||
@@ -723,7 +739,7 @@ void UISettingsPanel::materializeCategory( Impl& panel, const std::string& categ
|
||||
if ( auto* value = std::get_if<BoolPointerSetting>( &setting.value ) ) {
|
||||
auto* check = createBoolControl( panel, setting, view );
|
||||
auto binding = UIDataBind<bool>::New( value->value, check,
|
||||
UIValueConverter<bool>::converterBool() );
|
||||
UIValueConverter<bool>::converterBool() );
|
||||
binding->onValueChangeCb = value->apply;
|
||||
panel.bindingGroup += std::move( binding );
|
||||
} else if ( auto* value = std::get_if<BoolSetting>( &setting.value ) ) {
|
||||
@@ -832,6 +848,9 @@ void UISettingsPanel::materializeCategory( Impl& panel, const std::string& categ
|
||||
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK )
|
||||
value->action();
|
||||
} );
|
||||
} else if ( auto* value = std::get_if<CustomWidgetSetting>( &setting.value ) ) {
|
||||
auto* row = createRow( panel, setting, view, SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT.root() );
|
||||
value->create( row->find<UIWidget>( "setting_custom_widget" ) );
|
||||
}
|
||||
if ( view.row && !setting.enabled )
|
||||
setNodeTreeEnabled( view.row, false );
|
||||
|
||||
32
src/tests/unit_tests/fileassociation_tests.cpp
Normal file
32
src/tests/unit_tests/fileassociation_tests.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#include "utest.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <eepp/system/fileassociation.hpp>
|
||||
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
|
||||
|
||||
using namespace EE::System;
|
||||
using namespace EE::UI::Doc;
|
||||
|
||||
UTEST( FileAssociation, normalizesExtensions ) {
|
||||
EXPECT_STDSTREQ( "cpp", FileAssociation::normalizeExtension( ".CPP" ) );
|
||||
EXPECT_STDSTREQ( "d.ts", FileAssociation::normalizeExtension( "..D.TS" ) );
|
||||
EXPECT_STDSTREQ( "c++", FileAssociation::normalizeExtension( "c++" ) );
|
||||
EXPECT_TRUE( FileAssociation::normalizeExtension( "bad/ext" ).empty() );
|
||||
EXPECT_TRUE( FileAssociation::normalizeExtension( "." ).empty() );
|
||||
}
|
||||
|
||||
UTEST( SyntaxDefinitionManager, listsLiteralFileExtensionsWithoutLoadingDefinitions ) {
|
||||
auto* manager = SyntaxDefinitionManager::createSingleton();
|
||||
manager->addPreDefinition( { "Unit Test Language",
|
||||
[]() -> SyntaxDefinition& {
|
||||
static SyntaxDefinition definition;
|
||||
return definition;
|
||||
},
|
||||
{ "%.unitext$", "^unitfile$" } } );
|
||||
const auto extensions = manager->getFileExtensions();
|
||||
|
||||
EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "cpp" ) );
|
||||
EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "json" ) );
|
||||
EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "unitext" ) );
|
||||
EXPECT_FALSE( std::binary_search( extensions.begin(), extensions.end(), "unitfile" ) );
|
||||
}
|
||||
@@ -87,6 +87,12 @@ UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) {
|
||||
EXPECT_TRUE( firstRow->isVisible() );
|
||||
EXPECT_EQ( nullptr, panel->find<UIWidget>( "setting_second" ) );
|
||||
EXPECT_FALSE( panel->addCategory( "late.category", "Late", "Category" ) );
|
||||
EXPECT_FALSE( panel->addCustomWidget(
|
||||
{ "lateWidget", "general.behavior", "Late", "Late widget", {} }, []( UIWidget* parent ) {
|
||||
auto* widget = UITextView::New();
|
||||
widget->setParent( parent );
|
||||
return widget;
|
||||
} ) );
|
||||
|
||||
panel->selectCategory( "editor.display" );
|
||||
auto* secondRow = panel->find<UIWidget>( "setting_second" );
|
||||
@@ -102,6 +108,28 @@ UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) {
|
||||
EXPECT_TRUE( secondRow->isEnabled() );
|
||||
}
|
||||
|
||||
UTEST( UISettingsPanel, materializesCustomWidgets ) {
|
||||
UIApplication app(
|
||||
WindowSettings( 800, 600, "eepp - UISettingsPanel Custom Widget Test", WindowStyle::Default,
|
||||
WindowBackend::Default, 32 ),
|
||||
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
|
||||
auto* panel = UISettingsPanel::New( app.getUI()->getRoot() );
|
||||
EXPECT_TRUE( panel->addCategory( "general.integration", "General", "Integration" ) );
|
||||
EXPECT_TRUE( panel->addCustomWidget(
|
||||
{ "associations", "general.integration", "Associations", "Select extensions", {} },
|
||||
[]( UIWidget* parent ) {
|
||||
auto* widget = UITextView::New();
|
||||
widget->setId( "custom_associations" );
|
||||
widget->setParent( parent );
|
||||
return widget;
|
||||
} ) );
|
||||
|
||||
panel->build();
|
||||
|
||||
EXPECT_NE( nullptr, panel->find<UIWidget>( "setting_associations" ) );
|
||||
EXPECT_NE( nullptr, panel->find<UIWidget>( "custom_associations" ) );
|
||||
}
|
||||
|
||||
UTEST( UISettingsPanel, filtersAcrossUnmaterializedCategories ) {
|
||||
UIApplication app(
|
||||
WindowSettings( 800, 600, "eepp - UISettingsPanel Filter Test", WindowStyle::Default,
|
||||
|
||||
@@ -6,10 +6,187 @@
|
||||
#include "settingsdocument.hpp"
|
||||
#include "settingspage.hpp"
|
||||
#include "uitreeviewfs.hpp"
|
||||
#include <atomic>
|
||||
#include <eepp/system/fileassociation.hpp>
|
||||
#include <limits>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ecode {
|
||||
|
||||
class FileAssociationsModel final : public Model {
|
||||
public:
|
||||
enum Columns { Registered, Extension, Count };
|
||||
|
||||
struct Entry {
|
||||
std::string extension;
|
||||
bool registered{ false };
|
||||
};
|
||||
|
||||
FileAssociationsModel( std::vector<std::string> extensions,
|
||||
const std::vector<std::string>& registered, String registeredColumn,
|
||||
String extensionColumn ) :
|
||||
mRegisteredColumn( std::move( registeredColumn ) ),
|
||||
mExtensionColumn( std::move( extensionColumn ) ) {
|
||||
std::unordered_set<std::string> registeredSet( registered.begin(), registered.end() );
|
||||
mEntries.reserve( extensions.size() );
|
||||
for ( auto& extension : extensions )
|
||||
mEntries.push_back( { extension, registeredSet.contains( extension ) } );
|
||||
}
|
||||
|
||||
size_t rowCount( const ModelIndex& = {} ) const { return mEntries.size(); }
|
||||
|
||||
size_t columnCount( const ModelIndex& = {} ) const { return Count; }
|
||||
|
||||
std::string columnName( const size_t& column ) const {
|
||||
return column == Registered ? mRegisteredColumn.toUtf8() : mExtensionColumn.toUtf8();
|
||||
}
|
||||
|
||||
Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const {
|
||||
if ( !index.isValid() || static_cast<size_t>( index.row() ) >= mEntries.size() )
|
||||
return {};
|
||||
const auto& entry = mEntries[index.row()];
|
||||
if ( role == ModelRole::Data && index.column() == Registered )
|
||||
return Variant( entry.registered );
|
||||
if ( role == ModelRole::Display && index.column() == Extension )
|
||||
return Variant( '.' + entry.extension );
|
||||
return {};
|
||||
}
|
||||
|
||||
void setRegistered( size_t row, bool registered ) {
|
||||
if ( row < mEntries.size() )
|
||||
mEntries[row].registered = registered;
|
||||
}
|
||||
|
||||
void setAllRegistered( bool registered ) {
|
||||
for ( auto& entry : mEntries )
|
||||
entry.registered = registered;
|
||||
invalidate( Model::UpdateFlag::DontInvalidateIndexes );
|
||||
}
|
||||
|
||||
std::vector<std::string> extensions() const {
|
||||
std::vector<std::string> extensions;
|
||||
extensions.reserve( mEntries.size() );
|
||||
for ( const auto& entry : mEntries )
|
||||
extensions.emplace_back( entry.extension );
|
||||
return extensions;
|
||||
}
|
||||
|
||||
std::vector<std::string> registeredExtensions() const {
|
||||
std::vector<std::string> extensions;
|
||||
for ( const auto& entry : mEntries ) {
|
||||
if ( entry.registered )
|
||||
extensions.emplace_back( entry.extension );
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<Entry> mEntries;
|
||||
String mRegisteredColumn;
|
||||
String mExtensionColumn;
|
||||
};
|
||||
|
||||
class FileAssociationTableCell final : public UITableCell {
|
||||
public:
|
||||
static FileAssociationTableCell* New( const std::string& tag, FileAssociationsModel* model,
|
||||
ModelIndex index ) {
|
||||
return eeNew( FileAssociationTableCell, ( tag, model, index ) );
|
||||
}
|
||||
|
||||
FileAssociationTableCell( const std::string& tag, FileAssociationsModel* model,
|
||||
ModelIndex index ) :
|
||||
UITableCell( tag,
|
||||
[model, index]( UIPushButton* ) -> UITextView* {
|
||||
auto* check = UICheckBox::New();
|
||||
check->setCheckMode( UICheckBox::Button );
|
||||
check->setChecked( model->data( index, ModelRole::Data ).asBool() );
|
||||
return check;
|
||||
} ),
|
||||
mModel( model ) {}
|
||||
|
||||
void updateCell( Model* model ) {
|
||||
if ( mTextBox->isType( UI_TYPE_CHECKBOX ) ) {
|
||||
auto* check = mTextBox->asType<UICheckBox>();
|
||||
mUpdating = true;
|
||||
check->setChecked( model->data( getCurIndex(), ModelRole::Data ).asBool() );
|
||||
mUpdating = false;
|
||||
if ( !mListening ) {
|
||||
check->on( Event::OnValueChange, [this, check]( const Event* ) {
|
||||
if ( !mUpdating )
|
||||
mModel->setRegistered( getCurIndex().row(), check->isChecked() );
|
||||
} );
|
||||
mListening = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
FileAssociationsModel* mModel{ nullptr };
|
||||
bool mUpdating{ false };
|
||||
bool mListening{ false };
|
||||
};
|
||||
|
||||
class UIFileAssociationsTableView final : public UITableView {
|
||||
public:
|
||||
static UIFileAssociationsTableView* New() { return eeNew( UIFileAssociationsTableView, () ); }
|
||||
|
||||
UIWidget* createCell( UIWidget* rowWidget, const ModelIndex& index ) {
|
||||
if ( index.column() == FileAssociationsModel::Registered ) {
|
||||
auto* cell = FileAssociationTableCell::New(
|
||||
mTag + "::cell", static_cast<FileAssociationsModel*>( getModel() ), index );
|
||||
cell->getTextView()->setEnabled( true );
|
||||
cell->setDontAutoHideEmptyTextBox( true );
|
||||
return setupCell( cell, rowWidget, index );
|
||||
}
|
||||
return UITableView::createCell( rowWidget, index );
|
||||
}
|
||||
|
||||
private:
|
||||
UIFileAssociationsTableView() : UITableView() {}
|
||||
};
|
||||
|
||||
struct FileAssociationsViewState {
|
||||
std::shared_ptr<FileAssociationsModel> model;
|
||||
std::atomic<bool> applying{ false };
|
||||
UIWidget* layout{ nullptr };
|
||||
UICheckBox* desktopEntry{ nullptr };
|
||||
UIFileAssociationsTableView* table{ nullptr };
|
||||
UIPushButton* selectAll{ nullptr };
|
||||
UIPushButton* clear{ nullptr };
|
||||
UIPushButton* apply{ nullptr };
|
||||
|
||||
void setControlsEnabled( bool enabled ) {
|
||||
if ( desktopEntry )
|
||||
desktopEntry->setEnabled( enabled );
|
||||
if ( table )
|
||||
table->setEnabled( enabled );
|
||||
if ( selectAll )
|
||||
selectAll->setEnabled( enabled );
|
||||
if ( clear )
|
||||
clear->setEnabled( enabled );
|
||||
if ( apply )
|
||||
apply->setEnabled( enabled );
|
||||
}
|
||||
|
||||
void clearControls() {
|
||||
layout = nullptr;
|
||||
desktopEntry = nullptr;
|
||||
table = nullptr;
|
||||
selectAll = nullptr;
|
||||
clear = nullptr;
|
||||
apply = nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
static FileAssociationApplication fileAssociationApplication( App* app ) {
|
||||
auto executablePath = Sys::getProcessFilePath();
|
||||
#if EE_PLATFORM == EE_PLATFORM_WIN
|
||||
return { "ecode", "ecode", executablePath, "\"" + executablePath + "\",0" };
|
||||
#else
|
||||
return { "ecode", "ecode", std::move( executablePath ), app->resPath() + "icon/ecode.png" };
|
||||
#endif
|
||||
}
|
||||
|
||||
SettingsPanel::SettingsPanel( App* app ) :
|
||||
mApp( app ), mLifetime( this, app ? app->getUISceneNode() : nullptr ) {}
|
||||
|
||||
@@ -160,6 +337,11 @@ void SettingsPanel::addAction( PanelState& state, SettingDescriptor binding,
|
||||
state.panel->addAction( std::move( binding ), buttonText, std::move( action ) );
|
||||
}
|
||||
|
||||
void SettingsPanel::addCustomWidget( PanelState& state, SettingDescriptor binding,
|
||||
std::function<UIWidget*( UIWidget* parent )> create ) {
|
||||
state.panel->addCustomWidget( std::move( binding ), std::move( create ) );
|
||||
}
|
||||
|
||||
void SettingsPanel::refreshTextSetting( PanelState& state, const std::string& id ) {
|
||||
state.panel->refreshTextSetting( id );
|
||||
}
|
||||
@@ -212,6 +394,119 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
|
||||
&mApp->getConfig().ui.smoothScroll,
|
||||
[this]( bool value ) { mApp->getUISceneNode()->setSmoothScrollEnabled( value, true ); } );
|
||||
|
||||
if ( FileAssociation::isSupported() ) {
|
||||
addCategory( panel, "general.file_associations", mApp->i18n( "general", "General" ),
|
||||
mApp->i18n( "file_associations", "File Associations" ) );
|
||||
auto application = fileAssociationApplication( mApp );
|
||||
auto extensions = SyntaxDefinitionManager::instance()->getFileExtensions();
|
||||
FileAssociation association( application );
|
||||
auto registered = association.getRegisteredExtensions( extensions );
|
||||
auto viewState = std::make_shared<FileAssociationsViewState>();
|
||||
viewState->model = std::make_shared<FileAssociationsModel>(
|
||||
std::move( extensions ), registered, mApp->i18n( "registered", "Registered" ),
|
||||
mApp->i18n( "extension", "Extension" ) );
|
||||
const bool desktopEntryInstalled = association.isDesktopEntryInstalled();
|
||||
addCustomWidget(
|
||||
panel,
|
||||
{ "systemFileAssociations", "general.file_associations",
|
||||
mApp->i18n( "system_file_associations", "System File Associations" ),
|
||||
mApp->i18n(
|
||||
"system_file_associations_desc",
|
||||
"Make ecode available for the selected file extensions. The operating system may "
|
||||
"still ask you to confirm the default application." ) },
|
||||
[this, application = std::move( application ), viewState,
|
||||
desktopEntryInstalled]( UIWidget* parent ) {
|
||||
static constexpr const char* layoutSource = R"xml(
|
||||
<vbox id="file_associations_container" lw="mp" lh="wc">
|
||||
<CheckBox id="install_desktop_entry" lw="wc" lh="wc" margin-bottom="6dp" />
|
||||
<vbox id="file_associations_table_container" lw="mp" lh="320dp" />
|
||||
<hbox lw="mp" lh="wc" margin-top="8dp" gravity="right">
|
||||
<PushButton id="select_all_associations" lw="wc" lh="wc" margin-right="4dp" />
|
||||
<PushButton id="clear_associations" lw="wc" lh="wc" margin-right="4dp" />
|
||||
<PushButton id="apply_associations" lw="wc" lh="wc" />
|
||||
</hbox>
|
||||
</vbox>
|
||||
)xml";
|
||||
auto* layout =
|
||||
parent->getUISceneNode()->loadLayoutFromString( layoutSource, parent );
|
||||
auto* desktopEntry = layout->find<UICheckBox>( "install_desktop_entry" );
|
||||
viewState->layout = layout;
|
||||
viewState->desktopEntry = desktopEntry;
|
||||
layout->on( Event::OnClose, [viewState, layout]( const Event* ) {
|
||||
if ( viewState->layout == layout )
|
||||
viewState->clearControls();
|
||||
} );
|
||||
desktopEntry->setText(
|
||||
mApp->i18n( "install_desktop_entry", "Install application launcher" ) );
|
||||
desktopEntry->setChecked( desktopEntryInstalled );
|
||||
desktopEntry->setVisible( FileAssociation::supportsDesktopEntries() );
|
||||
|
||||
auto* table = UIFileAssociationsTableView::New();
|
||||
viewState->table = table;
|
||||
table->setParent( layout->find<UIWidget>( "file_associations_table_container" ) );
|
||||
table->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
|
||||
table->setColumnWidthMode( UIAbstractTableView::ColumnWidthMode::Percentage );
|
||||
table->setColumnsWidthPercentage( { 0.2f, 0.8f } );
|
||||
table->setModel( viewState->model );
|
||||
table->setHeadersVisible( true );
|
||||
|
||||
auto* selectAll = layout->find<UIPushButton>( "select_all_associations" );
|
||||
viewState->selectAll = selectAll;
|
||||
selectAll->setText( mApp->i18n( "select_all", "Select All" ) );
|
||||
selectAll->onClick( [viewState]( const MouseEvent* ) {
|
||||
viewState->model->setAllRegistered( true );
|
||||
} );
|
||||
auto* clear = layout->find<UIPushButton>( "clear_associations" );
|
||||
viewState->clear = clear;
|
||||
clear->setText( mApp->i18n( "clear", "Clear" ) );
|
||||
clear->onClick( [viewState]( const MouseEvent* ) {
|
||||
viewState->model->setAllRegistered( false );
|
||||
} );
|
||||
auto* apply = layout->find<UIPushButton>( "apply_associations" );
|
||||
viewState->apply = apply;
|
||||
apply->setText( mApp->i18n( "apply", "Apply" ) );
|
||||
apply->onClick( [this, application, viewState]( const MouseEvent* ) {
|
||||
if ( viewState->applying.exchange( true ) )
|
||||
return;
|
||||
viewState->setControlsEnabled( false );
|
||||
auto selected = viewState->model->registeredExtensions();
|
||||
auto supported = viewState->model->extensions();
|
||||
const bool installDesktopEntry =
|
||||
( viewState->desktopEntry && viewState->desktopEntry->isChecked() ) ||
|
||||
( FileAssociation::supportsDesktopEntries() && !selected.empty() );
|
||||
auto lifetime = mLifetime.weakHandle();
|
||||
mApp->getThreadPool()->run( [application, selected = std::move( selected ),
|
||||
supported = std::move( supported ),
|
||||
installDesktopEntry, viewState,
|
||||
lifetime]() mutable {
|
||||
FileAssociation fileAssociation( std::move( application ) );
|
||||
const bool success = fileAssociation.setRegisteredExtensions(
|
||||
selected, supported, installDesktopEntry );
|
||||
auto error = fileAssociation.getLastError();
|
||||
viewState->applying = false;
|
||||
lifetime.run( [success, installDesktopEntry, viewState,
|
||||
error = std::move( error )]( SettingsPanel* settings ) {
|
||||
viewState->setControlsEnabled( true );
|
||||
if ( success ) {
|
||||
if ( viewState->desktopEntry )
|
||||
viewState->desktopEntry->setChecked( installDesktopEntry );
|
||||
settings->mApp->getNotificationCenter()->addNotification(
|
||||
settings->mApp->i18n( "file_associations_updated",
|
||||
"File associations updated." ) );
|
||||
} else {
|
||||
settings->mApp->errorMsgBox(
|
||||
settings->mApp->i18n( "file_associations_update_failed",
|
||||
"Could not update file associations." ) +
|
||||
( error.empty() ? String{}
|
||||
: "\n" + String::fromUtf8( error ) ) );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
return layout;
|
||||
} );
|
||||
}
|
||||
|
||||
addCategory( panel, "editor.appearance", mApp->i18n( "editor", "Editor" ),
|
||||
mApp->i18n( "appearance", "Appearance" ) );
|
||||
std::vector<String> editorSchemeNames;
|
||||
@@ -1165,7 +1460,7 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
|
||||
[this, monitorRefreshRate, unlimitedFrameRate] {
|
||||
const auto value = mApp->getConfig().context.FrameRateLimit;
|
||||
return value == ContextSettings::FrameRateLimitScreenRefreshRate ? monitorRefreshRate
|
||||
: value == 0 ? unlimitedFrameRate
|
||||
: value == 0 ? unlimitedFrameRate
|
||||
: String( String::toString( value ) );
|
||||
},
|
||||
[this, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) {
|
||||
|
||||
@@ -77,6 +77,9 @@ class SettingsPanel {
|
||||
void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText,
|
||||
std::function<void()> action );
|
||||
|
||||
void addCustomWidget( PanelState& state, SettingDescriptor binding,
|
||||
std::function<UIWidget*( UIWidget* parent )> create );
|
||||
|
||||
void refreshTextSetting( PanelState& state, const std::string& id );
|
||||
|
||||
void setCategoryEnabled( PanelState& state, const std::string& category, bool enabled,
|
||||
|
||||
Reference in New Issue
Block a user