1 /** 2 * Finding associations between MIME types and applications. 3 * Authors: 4 * $(LINK2 https://github.com/FreeSlave, Roman Chistokhodov) 5 * Copyright: 6 * Roman Chistokhodov, 2016 7 * License: 8 * $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0). 9 * See_Also: 10 * $(LINK2 https://www.freedesktop.org/wiki/Specifications/mime-apps-spec/, MIME Applications Associations) 11 */ 12 13 module mimeapps; 14 15 private { 16 import std.algorithm; 17 import std.array; 18 import std.exception; 19 import std.file; 20 import std.path; 21 import std.range; 22 import std.traits; 23 24 import xdgpaths; 25 import isfreedesktop; 26 import findexecutable; 27 } 28 29 public import desktopfile.file; 30 31 private @nogc @trusted auto parseMimeTypeName(String)(String name) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) 32 { 33 alias Tuple!(String, "media", String, "subtype") MimeTypeName; 34 35 String media; 36 String subtype; 37 38 size_t i; 39 for (i=0; i<name.length; ++i) { 40 if (name[i] == '/') { 41 media = name[0..i]; 42 subtype = name[i+1..$]; 43 break; 44 } 45 } 46 47 return MimeTypeName(media, subtype); 48 } 49 50 unittest 51 { 52 auto t = parseMimeTypeName("text/plain"); 53 assert(t.media == "text" && t.subtype == "plain"); 54 55 t = parseMimeTypeName("not mime type"); 56 assert(t.media == string.init && t.subtype == string.init); 57 } 58 59 private @nogc @trusted bool allSymbolsAreValid(scope const(char)[] name) nothrow pure 60 { 61 import std.ascii : isAlpha, isDigit; 62 for (size_t i=0; i<name.length; ++i) { 63 char c = name[i]; 64 if (!(c.isAlpha || c.isDigit || c == '-' || c == '+' || c == '.' || c == '_')) { 65 return false; 66 } 67 } 68 return true; 69 } 70 71 private @nogc @safe bool isValidMimeTypeName(scope const(char)[] name) nothrow pure 72 { 73 auto t = parseMimeTypeName(name); 74 return t.media.length && t.subtype.length && allSymbolsAreValid(t.media) && allSymbolsAreValid(t.subtype); 75 } 76 77 unittest 78 { 79 assert( isValidMimeTypeName("text/plain")); 80 assert( isValidMimeTypeName("text/plain2")); 81 assert( isValidMimeTypeName("text/vnd.type")); 82 assert( isValidMimeTypeName("x-scheme-handler/http")); 83 assert(!isValidMimeTypeName("not mime type")); 84 assert(!isValidMimeTypeName("not()/valid")); 85 assert(!isValidMimeTypeName("not/valid{}")); 86 assert(!isValidMimeTypeName("text/")); 87 assert(!isValidMimeTypeName("/plain")); 88 assert(!isValidMimeTypeName("/")); 89 } 90 91 private @trusted void validateMimeType(string groupName, string mimeType, string value) { 92 if (!isValidMimeTypeName(mimeType)) { 93 throw new IniLikeEntryException("Invalid MIME type name", groupName, mimeType, value); 94 } 95 } 96 97 static if (isFreedesktop) 98 { 99 version(unittest) { 100 import std.process : environment; 101 102 package struct EnvGuard 103 { 104 this(string env, string newValue) { 105 envVar = env; 106 envValue = environment.get(env); 107 environment[env] = newValue; 108 } 109 110 ~this() { 111 if (envValue is null) { 112 environment.remove(envVar); 113 } else { 114 environment[envVar] = envValue; 115 } 116 } 117 118 string envVar; 119 string envValue; 120 } 121 } 122 123 private enum mimeAppsList = "mimeapps.list"; 124 private enum applicationsMimeAppsList = "applications/mimeapps.list"; 125 126 /// Get desktop prefixes for mimeapps.list overrides. 127 @safe auto getDesktopPrefixes() nothrow 128 { 129 import std.process : environment; 130 import std.uni : toLower; 131 import std.utf : byCodeUnit; 132 133 string xdgCurrentDesktop; 134 collectException(environment.get("XDG_CURRENT_DESKTOP"), xdgCurrentDesktop); 135 return xdgCurrentDesktop.byCodeUnit.splitter(':').map!(prefix => prefix.toLower); 136 } 137 138 /// 139 unittest 140 { 141 auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "LXQT"); 142 assert(equal(getDesktopPrefixes(), ["lxqt"])); 143 144 environment["XDG_CURRENT_DESKTOP"] = "unity:GNOME"; 145 assert(equal(getDesktopPrefixes(), ["unity", "gnome"])); 146 } 147 148 private @safe string[] getDesktopPrefixesArray() nothrow 149 { 150 string[] toReturn; 151 collectException(getDesktopPrefixes().array, toReturn); 152 return toReturn; 153 } 154 155 /** 156 * Find all writable mimeapps.list files locations including specific for the current desktop. 157 * Found paths are not checked for existence or write access. 158 * 159 * $(BLUE This function is Freedesktop only). 160 * See_Also: $(D userMimeAppsListPaths), $(D userAppDataMimeAppsListPaths) 161 */ 162 deprecated @safe string[] writableMimeAppsListPaths() nothrow 163 { 164 return userMimeAppsListPaths() ~ userAppDataMimeAppsListPaths(); 165 } 166 167 /// ditto, but using user-provided prefix. 168 deprecated @safe string[] writableMimeAppsListPaths(const string[] desktopPrefixes) nothrow 169 { 170 return userMimeAppsListPaths(desktopPrefixes) ~ userAppDataMimeAppsListPaths(desktopPrefixes); 171 } 172 173 /** 174 * Find mimeapps.list files locations for user overrides including specific for the current desktop. 175 * Found paths are not checked for existence or write access. 176 * 177 * $(BLUE This function is Freedesktop only). 178 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location) 179 */ 180 @safe string[] userMimeAppsListPaths() nothrow 181 { 182 return userMimeAppsListPaths(getDesktopPrefixesArray()); 183 } 184 185 /// 186 unittest 187 { 188 auto configHomeGuard = EnvGuard("XDG_CONFIG_HOME", "/home/user/config"); 189 auto configDirsGuard = EnvGuard("XDG_CONFIG_DIRS", "/etc/xdg"); 190 191 auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data"); 192 auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data"); 193 194 auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME"); 195 196 assert(userMimeAppsListPaths() == ["/home/user/config/unity-mimeapps.list", "/home/user/config/gnome-mimeapps.list", "/home/user/config/mimeapps.list"]); 197 } 198 199 /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included). 200 @safe string[] userMimeAppsListPaths(const string[] desktopPrefixes) nothrow 201 { 202 string[] toReturn; 203 string configHome = xdgConfigHome(); 204 if (configHome.length) { 205 foreach(string desktopPrefix; desktopPrefixes) 206 { 207 toReturn ~= buildPath(configHome, desktopPrefix ~ "-" ~ mimeAppsList); 208 } 209 toReturn ~= buildPath(configHome, mimeAppsList); 210 } 211 return toReturn; 212 } 213 214 /** 215 * Find mimeapps.list files deprecated locations for user overrides including specific for the current desktop. 216 * Found paths are not checked for existence or write access. 217 * These locations are kept for compatibility. 218 * 219 * $(BLUE This function is Freedesktop only). 220 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location) 221 */ 222 @safe string[] userAppDataMimeAppsListPaths() nothrow 223 { 224 return userAppDataMimeAppsListPaths(getDesktopPrefixesArray()); 225 } 226 227 /// 228 unittest 229 { 230 auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data"); 231 auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME"); 232 233 assert(userAppDataMimeAppsListPaths() == ["/home/user/data/applications/unity-mimeapps.list", "/home/user/data/applications/gnome-mimeapps.list", "/home/user/data/applications/mimeapps.list"]); 234 } 235 236 /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included). 237 @safe string[] userAppDataMimeAppsListPaths(const string[] desktopPrefixes) nothrow 238 { 239 string[] toReturn; 240 string appHome = xdgDataHome("applications"); 241 if (appHome.length) { 242 foreach(string desktopPrefix; desktopPrefixes) 243 { 244 toReturn ~= buildPath(appHome, desktopPrefix ~ "-" ~ mimeAppsList); 245 } 246 toReturn ~= buildPath(appHome, mimeAppsList); 247 } 248 return toReturn; 249 } 250 251 /** 252 * Find mimeapps.list files locations for sysadmin and ISV overrides including specific for the current desktop. 253 * Found paths are not checked for existence. 254 * 255 * $(BLUE This function is Freedesktop only). 256 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location) 257 */ 258 @safe string[] vendorMimeAppsListPaths() nothrow 259 { 260 return vendorMimeAppsListPaths(getDesktopPrefixesArray()); 261 } 262 263 /// 264 unittest 265 { 266 auto configDirsGuard = EnvGuard("XDG_CONFIG_DIRS", "/etc/xdg"); 267 auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME"); 268 269 assert(vendorMimeAppsListPaths() == ["/etc/xdg/unity-mimeapps.list", "/etc/xdg/gnome-mimeapps.list", "/etc/xdg/mimeapps.list"]); 270 } 271 272 /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included). 273 @safe string[] vendorMimeAppsListPaths(const string[] desktopPrefixes) nothrow 274 { 275 string[] toReturn; 276 string[] configDirs = xdgConfigDirs(); 277 278 foreach(string desktopPrefix; desktopPrefixes) 279 { 280 foreach(configDir; configDirs) 281 { 282 toReturn ~= buildPath(configDir, desktopPrefix ~ "-" ~ mimeAppsList); 283 } 284 } 285 foreach(configDir; configDirs) 286 { 287 toReturn ~= buildPath(configDir, mimeAppsList); 288 } 289 return toReturn; 290 } 291 292 /** 293 * Find mimeapps.list files locations for distribution provided defaults including specific for the current desktop. 294 * Found paths are not checked for existence. 295 * 296 * $(BLUE This function is Freedesktop only). 297 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location) 298 */ 299 @safe string[] distributionMimeAppsListPaths() nothrow 300 { 301 return distributionMimeAppsListPaths(getDesktopPrefixesArray()); 302 } 303 304 /// 305 unittest 306 { 307 auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data"); 308 auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME"); 309 310 assert(distributionMimeAppsListPaths() == [ 311 "/usr/local/data/applications/unity-mimeapps.list", "/usr/data/applications/unity-mimeapps.list", 312 "/usr/local/data/applications/gnome-mimeapps.list", "/usr/data/applications/gnome-mimeapps.list", 313 "/usr/local/data/applications/mimeapps.list", "/usr/data/applications/mimeapps.list"]); 314 } 315 316 /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included). 317 @safe string[] distributionMimeAppsListPaths(const string[] desktopPrefixes) nothrow 318 { 319 string[] toReturn; 320 string[] appDataDirs = xdgDataDirs("applications"); 321 322 foreach(string desktopPrefix; desktopPrefixes) 323 { 324 foreach(appDataDir; appDataDirs) 325 { 326 toReturn ~= buildPath(appDataDir, desktopPrefix ~ "-" ~ mimeAppsList); 327 } 328 } 329 foreach(appDataDir; appDataDirs) 330 { 331 toReturn ~= buildPath(appDataDir, mimeAppsList); 332 } 333 return toReturn; 334 } 335 336 /** 337 * Find all known mimeapps.list files locations. 338 * Found paths are not checked for existence. 339 * 340 * $(BLUE This function is Freedesktop only). 341 * Returns: Paths of mimeapps.list files in the system. 342 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location) 343 */ 344 @safe string[] mimeAppsListPaths() nothrow 345 { 346 return mimeAppsListPaths(getDesktopPrefixesArray()); 347 } 348 349 /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included). 350 @safe string[] mimeAppsListPaths(const string[] desktopPrefixes) nothrow 351 { 352 return userMimeAppsListPaths(desktopPrefixes) ~ vendorMimeAppsListPaths(desktopPrefixes) ~ 353 userAppDataMimeAppsListPaths(desktopPrefixes) ~ distributionMimeAppsListPaths(desktopPrefixes); 354 } 355 356 /** 357 * Find all known mimeinfo.cache files locations. Found paths are not checked for existence. 358 * 359 * $(BLUE This function is Freedesktop only). 360 * Returns: Paths of mimeinfo.cache files in the system. 361 */ 362 @safe string[] mimeInfoCachePaths() nothrow 363 { 364 return xdgAllDataDirs("applications/mimeinfo.cache"); 365 } 366 367 /// 368 unittest 369 { 370 auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data"); 371 auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data"); 372 373 assert(mimeInfoCachePaths() == [ 374 "/home/user/data/applications/mimeinfo.cache", 375 "/usr/local/data/applications/mimeinfo.cache", "/usr/data/applications/mimeinfo.cache" 376 ]); 377 } 378 } 379 380 /** 381 * $(D inilike.file.IniLikeGroup) subclass for easy access to the list of applications associated with given type. 382 */ 383 final class MimeAppsGroup : IniLikeGroup 384 { 385 protected @nogc @safe this(string groupName) nothrow { 386 super(groupName); 387 } 388 389 /** 390 * Split string list of desktop ids into range. 391 * See_Also: $(D joinApps) 392 */ 393 static @trusted auto splitApps(string apps) { 394 return std.algorithm.splitter(apps, ";").filter!(s => !s.empty); 395 } 396 397 /// 398 unittest 399 { 400 assert(splitApps("kde4-kate.desktop;kde4-kwrite.desktop;geany.desktop;").equal(["kde4-kate.desktop", "kde4-kwrite.desktop", "geany.desktop"])); 401 } 402 403 /** 404 * Join list of desktop ids into string. 405 * See_Also: $(D splitApps) 406 */ 407 static @trusted string joinApps(Range)(Range apps, bool trailingSemicolon = true) if (isInputRange!Range && is(ElementType!Range : string)) 408 { 409 auto result = apps.filter!( s => !s.empty ).joiner(";"); 410 if (result.empty) { 411 return null; 412 } else { 413 return text(result) ~ (trailingSemicolon ? ";" : ""); 414 } 415 } 416 417 /// 418 unittest 419 { 420 assert(joinApps(["kde4-kate.desktop", "kde4-kwrite.desktop", "geany.desktop"]) == "kde4-kate.desktop;kde4-kwrite.desktop;geany.desktop;"); 421 assert(joinApps((string[]).init) == string.init); 422 assert(joinApps(["geany.desktop"], false) == "geany.desktop"); 423 assert(joinApps(["geany.desktop"], true) == "geany.desktop;"); 424 assert(joinApps((string[]).init, true) == string.init); 425 } 426 427 /** 428 * List applications for given mimeType. 429 * Returns: Range of $(B Desktop id)s for mimeType. 430 */ 431 @safe auto appsForMimeType(string mimeType) const { 432 return splitApps(unescapedValue(mimeType)); 433 } 434 435 /** 436 * Delete desktopId from the list of desktop ids for mimeType. 437 */ 438 @trusted void deleteAssociation(string mimeType, string desktopId) 439 { 440 auto appsStr = unescapedValue(mimeType); 441 if (appsStr.length) { 442 const bool hasSemicolon = appsStr[$-1] == ';'; 443 auto apps = splitApps(appsStr); 444 if (apps.canFind(desktopId)) { 445 auto without = apps.filter!(d => d != desktopId); 446 if (without.empty) { 447 removeEntry(mimeType); 448 } else { 449 setUnescapedValue(mimeType, MimeAppsGroup.joinApps(without, hasSemicolon)); 450 } 451 } 452 } 453 } 454 455 /** 456 * Set list of desktop ids for mimeType. This overwrites existing list completely. 457 * Can be used to set the list of added assocations rearranged in client code to manage the preference order. 458 */ 459 @safe void setAssocations(Range)(string mimeType, Range desktopIds) if (isInputRange!Range && is(ElementType!Range : string)) 460 { 461 setUnescapedValue(mimeType, joinApps(desktopIds)); 462 } 463 464 protected: 465 @trusted override void validateKey(string key, string value) const { 466 validateMimeType(groupName(), key, value); 467 } 468 } 469 470 /** 471 * Class represenation of single mimeapps.list file containing information about MIME type associations and default applications. 472 */ 473 final class MimeAppsListFile : IniLikeFile 474 { 475 /** 476 * Read mimeapps.list file. 477 * Throws: 478 * $(B ErrnoException) if file could not be opened. 479 * $(D inilike.file.IniLikeReadException) if error occured while reading the file or "MIME Cache" group is missing. 480 */ 481 @trusted this(string fileName, ReadOptions readOptions = ReadOptions.init) 482 { 483 this(iniLikeFileReader(fileName), fileName, readOptions); 484 } 485 486 /** 487 * Read MIME type associations from $(D inilike.range.IniLikeReader), e.g. acquired from $(D inilike.range.iniLikeFileReader) or $(D inilike.range.iniLikeStringReader). 488 * Throws: 489 * $(D inilike.file.IniLikeReadException) if error occured while parsing or "MIME Cache" group is missing. 490 */ 491 this(IniLikeReader)(IniLikeReader reader, string fileName = null, ReadOptions readOptions = ReadOptions.init) 492 { 493 super(reader, fileName, readOptions); 494 _defaultApps = cast(MimeAppsGroup)group("Default Applications"); 495 _addedApps = cast(MimeAppsGroup)group("Added Associations"); 496 _removedApps = cast(MimeAppsGroup)group("Removed Associations"); 497 } 498 499 this() 500 { 501 super(); 502 } 503 504 /** 505 * Access "Desktop Applications" group of default associations. 506 * Returns: $(D MimeAppsGroup) for "Desktop Applications" group or null if file does not have such group. 507 * See_Also: $(D addedAssociations) 508 */ 509 @safe inout(MimeAppsGroup) defaultApplications() nothrow inout pure { 510 return _defaultApps; 511 } 512 513 /** 514 * Access "Added Associations" group of explicitly added associations. 515 * Returns: $(D MimeAppsGroup) for "Added Associations" group or null if file does not have such group. 516 * See_Also: $(D defaultApplications), $(D removedAssociations) 517 */ 518 @safe inout(MimeAppsGroup) addedAssociations() nothrow inout pure { 519 return _addedApps; 520 } 521 522 /** 523 * Access "Removed Associations" group of explicitily removed associations. 524 * Returns: $(D MimeAppsGroup) for "Removed Associations" group or null if file does not have such group. 525 * See_Also: $(D addedAssociations) 526 */ 527 @safe inout(MimeAppsGroup) removedAssociations() nothrow inout pure { 528 return _removedApps; 529 } 530 531 /** 532 * Set desktopId as default application for mimeType. 533 * Set it as first element in the list of added associations. 534 * Delete it from removed associations if listed. 535 * Note: This only changes the object, but not file itself. 536 */ 537 @trusted void setDefaultApplication(string mimeType, string desktopId) 538 { 539 if (mimeType.empty || desktopId.empty) { 540 return; 541 } 542 ensureDefaultApplications(); 543 _defaultApps.setUnescapedValue(mimeType, desktopId); 544 ensureAddedAssociations(); 545 auto added = _addedApps.appsForMimeType(mimeType); 546 auto withoutThis = added.filter!(d => d != desktopId); 547 auto resorted = only(desktopId).chain(withoutThis); 548 _addedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(resorted)); 549 550 if (_removedApps !is null) { 551 _removedApps.deleteAssociation(mimeType, desktopId); 552 } 553 } 554 555 /// 556 unittest 557 { 558 MimeAppsListFile appsList = new MimeAppsListFile(); 559 appsList.setDefaultApplication("text/plain", "geany.desktop"); 560 assert(appsList.defaultApplications() !is null); 561 assert(appsList.addedAssociations() !is null); 562 assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"])); 563 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 564 565 appsList.setDefaultApplication("image/png", null); 566 assert(!appsList.defaultApplications().contains("image/png")); 567 568 string contents = 569 `[Default Applications] 570 text/plain=kde4-kate.desktop`; 571 572 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 573 appsList.setDefaultApplication("text/plain", "geany.desktop"); 574 assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"])); 575 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 576 577 contents = 578 `[Default Applications] 579 text/plain=kde4-kate.desktop 580 [Added Associations] 581 text/plain=kde4-kate.desktop;emacs.desktop;`; 582 583 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 584 appsList.setDefaultApplication("text/plain", "geany.desktop"); 585 assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"])); 586 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kate.desktop", "emacs.desktop"])); 587 588 contents = 589 `[Default Applications] 590 text/plain=emacs.desktop 591 [Added Associations] 592 text/plain=emacs.desktop;kde4-kate.desktop;geany.desktop;`; 593 594 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 595 appsList.setDefaultApplication("text/plain", "geany.desktop"); 596 assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"])); 597 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "emacs.desktop", "kde4-kate.desktop"])); 598 599 contents = 600 `[Default Applications] 601 text/plain=emacs.desktop 602 [Added Associations] 603 text/plain=emacs.desktop;kde4-kate.desktop; 604 [Removed Associations] 605 text/plain=kde4-okular.desktop;geany.desktop`; 606 607 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 608 appsList.setDefaultApplication("text/plain", "geany.desktop"); 609 assert(appsList.removedAssociations() !is null); 610 assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"])); 611 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "emacs.desktop", "kde4-kate.desktop"])); 612 assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"])); 613 } 614 615 /** 616 * Add desktopId as association for mimeType. 617 * Delete it from removed associations if listed. 618 * Note: This only changes the object, but not file itself. 619 */ 620 @trusted void addAssociation(string mimeType, string desktopId) 621 { 622 if (mimeType.empty || desktopId.empty) { 623 return; 624 } 625 ensureAddedAssociations(); 626 auto added = _addedApps.appsForMimeType(mimeType); 627 if (!added.canFind(desktopId)) { 628 _addedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(chain(added, only(desktopId)))); 629 } 630 if (_removedApps) { 631 _removedApps.deleteAssociation(mimeType, desktopId); 632 } 633 } 634 635 /// 636 unittest 637 { 638 MimeAppsListFile appsList = new MimeAppsListFile(); 639 appsList.addAssociation("text/plain", "geany.desktop"); 640 assert(appsList.addedAssociations() !is null); 641 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 642 643 appsList.addAssociation("image/png", null); 644 assert(!appsList.addedAssociations().contains("image/png")); 645 646 string contents = 647 `[Added Associations] 648 text/plain=kde4-kate.desktop;emacs.desktop 649 [Removed Associations] 650 text/plain=kde4-okular.desktop;geany.desktop;`; 651 652 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 653 appsList.addAssociation("text/plain", "geany.desktop"); 654 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["kde4-kate.desktop", "emacs.desktop", "geany.desktop"])); 655 assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"])); 656 657 contents = 658 `[Removed Associations] 659 text/plain=geany.desktop;`; 660 661 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 662 appsList.addAssociation("text/plain", "geany.desktop"); 663 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 664 assert(!appsList.removedAssociations().contains("text/plain")); 665 } 666 667 /** 668 * Explicitly remove desktopId association for mimeType. 669 * Delete it from added associations and default applications. 670 * Note: This only changes the object, but not file itself. 671 */ 672 @trusted void removeAssociation(string mimeType, string desktopId) 673 { 674 if (mimeType.empty || desktopId.empty) { 675 return; 676 } 677 ensureRemovedAssociations(); 678 auto removed = _removedApps.appsForMimeType(mimeType); 679 if (!removed.canFind(desktopId)) { 680 _removedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(chain(removed, only(desktopId)))); 681 } 682 if (_addedApps) { 683 _addedApps.deleteAssociation(mimeType, desktopId); 684 } 685 if (_defaultApps) { 686 _defaultApps.deleteAssociation(mimeType, desktopId); 687 } 688 } 689 690 /** 691 * Set list of desktop ids as assocations for $(D mimeType). This overwrites existing assocations. 692 * Note: This only changes the object, but not file itself. 693 * See_Also: $(D MimeAppsGroup.setAssocations) 694 */ 695 @trusted void setAddedAssocations(Range)(string mimeType, Range desktopIds) if (isInputRange!Range && is(ElementType!Range : string)) 696 { 697 ensureAddedAssociations(); 698 _addedApps.setAssocations(mimeType, desktopIds); 699 } 700 701 /// 702 unittest 703 { 704 MimeAppsListFile appsList = new MimeAppsListFile(); 705 appsList.removeAssociation("text/plain", "geany.desktop"); 706 assert(appsList.removedAssociations() !is null); 707 assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 708 709 appsList.removeAssociation("image/png", null); 710 assert(!appsList.removedAssociations().contains("image/png")); 711 712 string contents = 713 `[Default Applications] 714 text/plain=geany.desktop 715 [Added Associations] 716 text/plain=emacs.desktop;geany.desktop;kde4-kate.desktop;`; 717 718 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 719 appsList.removeAssociation("text/plain", "geany.desktop"); 720 assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 721 assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["emacs.desktop", "kde4-kate.desktop"])); 722 assert(!appsList.defaultApplications().contains("text/plain")); 723 724 contents = 725 `[Added Associations] 726 text/plain=geany.desktop;`; 727 728 appsList = new MimeAppsListFile(iniLikeStringReader(contents)); 729 appsList.removeAssociation("text/plain", "geany.desktop"); 730 assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"])); 731 assert(!appsList.addedAssociations().contains("text/plain")); 732 } 733 734 ///Create empty "Default Applications" group if it does not exist. 735 @trusted final void ensureDefaultApplications() { 736 ensureGroupExists(_defaultApps, "Default Applications"); 737 } 738 739 ///Create empty "Added Associations" group if it does not exist. 740 @trusted final void ensureAddedAssociations() { 741 ensureGroupExists(_addedApps, "Added Associations"); 742 } 743 744 ///Create empty "Removed Associations" group if it does not exist. 745 @trusted final void ensureRemovedAssociations() { 746 ensureGroupExists(_removedApps, "Removed Associations"); 747 } 748 749 protected: 750 @trusted override IniLikeGroup createGroupByName(string groupName) 751 { 752 if (groupName == "Default Applications") { 753 return new MimeAppsGroup(groupName); 754 } else if (groupName == "Added Associations") { 755 return new MimeAppsGroup(groupName); 756 } else if (groupName == "Removed Associations") { 757 return new MimeAppsGroup(groupName); 758 } else { 759 return null; 760 } 761 } 762 private: 763 @trusted final void ensureGroupExists(ref MimeAppsGroup group, string name) 764 { 765 if (group is null) { 766 group = new MimeAppsGroup(name); 767 insertGroup(group); 768 } 769 } 770 771 MimeAppsGroup _addedApps; 772 MimeAppsGroup _removedApps; 773 MimeAppsGroup _defaultApps; 774 } 775 776 /// 777 unittest 778 { 779 string content = 780 `[Added Associations] 781 text/plain=geany.desktop;kde4-kwrite.desktop; 782 image/png=kde4-gwenview.desktop;gthumb.desktop; 783 784 [Removed Associations] 785 text/plain=libreoffice-writer.desktop; 786 787 [Default Applications] 788 text/plain=kde4-kate.desktop 789 x-scheme-handler/http=chromium.desktop;iceweasel.desktop; 790 791 792 [Unknown group] 793 Key=Value`; 794 auto mimeAppsList = new MimeAppsListFile(iniLikeStringReader(content)); 795 assert(mimeAppsList.addedAssociations() !is null); 796 assert(mimeAppsList.removedAssociations() !is null); 797 assert(mimeAppsList.defaultApplications() !is null); 798 assert(mimeAppsList.group("Unknown group") is null); 799 800 assert(mimeAppsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"])); 801 assert(mimeAppsList.removedAssociations().appsForMimeType("text/plain").equal(["libreoffice-writer.desktop"])); 802 assert(mimeAppsList.defaultApplications().appsForMimeType("x-scheme-handler/http").equal(["chromium.desktop", "iceweasel.desktop"])); 803 804 content = 805 `[Default Applications] 806 text/plain=geany.desktop 807 notmimetype=value 808 `; 809 assertThrown!IniLikeReadException(new MimeAppsListFile(iniLikeStringReader(content))); 810 assertNotThrown(mimeAppsList = new MimeAppsListFile(iniLikeStringReader(content), null, IniLikeFile.ReadOptions(IniLikeGroup.InvalidKeyPolicy.save))); 811 assert(mimeAppsList.defaultApplications().escapedValue("notmimetype") == "value"); 812 } 813 814 /** 815 * Class represenation of single mimeinfo.cache file containing information about MIME type associations. 816 * Note: Unlike $(D MimeAppsListFile) this class does not provide functions for associations update. 817 * This is because mimeinfo.cache files should be updated by $(B update-desktop-database) utility from 818 * $(LINK2 https://www.freedesktop.org/wiki/Software/desktop-file-utils/, desktop-file-utils). 819 */ 820 final class MimeInfoCacheFile : IniLikeFile 821 { 822 /** 823 * Read MIME Cache from file. 824 * Throws: 825 * $(B ErrnoException) if file could not be opened. 826 * $(D inilike.file.IniLikeReadException) if error occured while reading the file or "MIME Cache" group is missing. 827 */ 828 @trusted this(string fileName, ReadOptions readOptions = ReadOptions.init) 829 { 830 this(iniLikeFileReader(fileName), fileName, readOptions); 831 } 832 833 /** 834 * Constructs MimeInfoCacheFile with empty MIME Cache group. 835 */ 836 @safe this() { 837 super(); 838 _mimeCache = new MimeAppsGroup("MIME Cache"); 839 insertGroup(_mimeCache); 840 } 841 842 /// 843 unittest 844 { 845 auto micf = new MimeInfoCacheFile(); 846 assert(micf.mimeCache() !is null); 847 } 848 849 /** 850 * Read MIME Cache from $(D inilike.range.IniLikeReader), e.g. acquired from $(D inilike.range.iniLikeFileReader) or $(D inilike.range.iniLikeStringReader). 851 * Throws: 852 * $(D inilike.file.IniLikeReadException) if error occured while parsing or "MIME Cache" group is missing. 853 */ 854 this(IniLikeReader)(IniLikeReader reader, string fileName = null, ReadOptions readOptions = ReadOptions.init) 855 { 856 super(reader, fileName, readOptions); 857 _mimeCache = cast(MimeAppsGroup)group("MIME Cache"); 858 enforce(_mimeCache !is null, new IniLikeReadException("No \"MIME Cache\" group", 0)); 859 } 860 861 /** 862 * Access "MIME Cache" group. 863 */ 864 @safe inout(MimeAppsGroup) mimeCache() nothrow inout pure { 865 return _mimeCache; 866 } 867 868 /** 869 * Alias for easy access to "MIME Cache" group. 870 */ 871 alias mimeCache this; 872 873 protected: 874 @trusted override IniLikeGroup createGroupByName(string groupName) 875 { 876 if (groupName == "MIME Cache") { 877 return new MimeAppsGroup(groupName); 878 } else { 879 return null; 880 } 881 } 882 private: 883 MimeAppsGroup _mimeCache; 884 } 885 886 /// 887 unittest 888 { 889 string content = 890 `[Some group] 891 Key=Value 892 `; 893 assertThrown!IniLikeReadException(new MimeInfoCacheFile(iniLikeStringReader(content))); 894 895 content = 896 `[MIME Cache] 897 text/plain=geany.desktop;kde4-kwrite.desktop; 898 image/png=kde4-gwenview.desktop;gthumb.desktop; 899 `; 900 901 auto mimeInfoCache = new MimeInfoCacheFile(iniLikeStringReader(content)); 902 assert(mimeInfoCache.appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"])); 903 assert(mimeInfoCache.appsForMimeType("image/png").equal(["kde4-gwenview.desktop", "gthumb.desktop"])); 904 assert(mimeInfoCache.appsForMimeType("application/nonexistent").empty); 905 906 content = 907 `[MIME Cache] 908 text/plain=geany.desktop; 909 notmimetype=value 910 `; 911 assertThrown!IniLikeReadException(new MimeInfoCacheFile(iniLikeStringReader(content))); 912 assertNotThrown(mimeInfoCache = new MimeInfoCacheFile(iniLikeStringReader(content), null, IniLikeFile.ReadOptions(IniLikeGroup.InvalidKeyPolicy.save))); 913 assert(mimeInfoCache.mimeCache.escapedValue("notmimetype") == "value"); 914 } 915 916 /** 917 * Create $(D MimeAppsListFile) objects for paths. 918 * Returns: Array of $(D MimeAppsListFile) objects read from paths. If some could not be read it's not included in the results. 919 */ 920 @trusted MimeAppsListFile[] mimeAppsListFiles(const(string)[] paths) nothrow 921 { 922 return paths.map!(function(string path) { 923 MimeAppsListFile file; 924 collectException(new MimeAppsListFile(path), file); 925 return file; 926 }).filter!(file => file !is null).array; 927 } 928 929 /// 930 unittest 931 { 932 assert(mimeAppsListFiles(["test/applications/mimeapps.list"]).length == 1); 933 } 934 935 static if (isFreedesktop) 936 { 937 /** 938 * ditto, but automatically read $(D MimeAppsListFile) objects from determined system paths. 939 * 940 * $(BLUE This function is Freedesktop only). 941 */ 942 @safe MimeAppsListFile[] mimeAppsListFiles() nothrow { 943 return mimeAppsListFiles(mimeAppsListPaths()); 944 } 945 } 946 947 /** 948 * Create $(D MimeInfoCacheFile) objects for paths. 949 * Returns: Array of $(D MimeInfoCacheFile) objects read from paths. If some could not be read it's not included in the results. 950 */ 951 @trusted MimeInfoCacheFile[] mimeInfoCacheFiles(const(string)[] paths) nothrow 952 { 953 return paths.map!(function(string path) { 954 MimeInfoCacheFile file; 955 collectException(new MimeInfoCacheFile(path), file); 956 return file; 957 }).filter!(file => file !is null).array; 958 } 959 960 /// 961 unittest 962 { 963 assert(mimeInfoCacheFiles(["test/applications/mimeinfo.cache"]).length == 1); 964 } 965 966 static if (isFreedesktop) 967 { 968 /** 969 * ditto, but automatically read MimeInfoCacheFile objects from determined system paths. 970 * 971 * $(BLUE This function is Freedesktop only). 972 */ 973 @safe MimeInfoCacheFile[] mimeInfoCacheFiles() nothrow { 974 return mimeInfoCacheFiles(mimeInfoCachePaths()); 975 } 976 } 977 978 /** 979 * Interface for desktop file provider. 980 * See_Also: $(D findAssociatedApplications), $(D findKnownAssociatedApplications), $(D findDefaultApplication) 981 */ 982 interface IDesktopFileProvider 983 { 984 /** 985 * Retrieve $(D desktopfile.file.DesktopFile) by desktopId 986 * Returns: Found $(D desktopfile.file.DesktopFile) or null if not found. 987 */ 988 const(DesktopFile) getByDesktopId(string desktopId); 989 } 990 991 /** 992 * Implementation of simple desktop file provider. 993 */ 994 class DesktopFileProvider : IDesktopFileProvider 995 { 996 private: 997 static struct DesktopFileItem 998 { 999 DesktopFile desktopFile; 1000 string baseDir; 1001 } 1002 1003 public: 1004 /** 1005 * Construct using given application paths. 1006 * Params: 1007 * applicationsPaths = Paths of applications/ directories where .desktop files are stored. These should be all known paths even if they don't exist at the time. 1008 * binPaths = Paths where executable files are stored. 1009 * options = Options used to read desktop files. 1010 */ 1011 @trusted this(in string[] applicationsPaths, in string[] binPaths, DesktopFile.DesktopReadOptions options = DesktopFile.DesktopReadOptions.init) { 1012 _baseDirs = applicationsPaths.dup; 1013 _readOptions = options; 1014 _binPaths = binPaths.dup; 1015 } 1016 1017 /// ditto, but determine binPaths from $(B PATH) environment variable automatically. 1018 @trusted this(in string[] applicationsPaths, DesktopFile.DesktopReadOptions options = DesktopFile.DesktopReadOptions.init) { 1019 this(applicationsPaths, binPaths().array, options); 1020 } 1021 1022 /** 1023 * Get DesktopFile by desktop id. 1024 * 1025 * This implementation searches $(B applicationsPaths) given in constructor to find, parse and cache .desktop file. 1026 * If found file has $(D TryExec) value it also checks if executable can be found in $(B binPaths). 1027 * Note: Exec value is left unchecked. 1028 */ 1029 override const(DesktopFile) getByDesktopId(string desktopId) 1030 { 1031 auto itemIn = desktopId in _cache; 1032 if (itemIn) { 1033 return itemIn.desktopFile; 1034 } else { 1035 auto foundItem = getDesktopFileItem(desktopId); 1036 if (foundItem.desktopFile !is null) { 1037 _cache[desktopId] = foundItem; 1038 return foundItem.desktopFile; 1039 } 1040 } 1041 return null; 1042 } 1043 private: 1044 DesktopFileItem getDesktopFileItem(string desktopId) 1045 { 1046 string baseDir; 1047 string filePath = findDesktopFilePath(desktopId, baseDir); 1048 if (filePath.length) { 1049 try { 1050 auto desktopFile = new DesktopFile(filePath, _readOptions); 1051 string tryExec = desktopFile.tryExecValue(); 1052 if (tryExec.length) { 1053 string executable = findExecutable(tryExec, _binPaths); 1054 if (executable.empty) { 1055 return DesktopFileItem.init; 1056 } 1057 } 1058 1059 return DesktopFileItem(desktopFile, baseDir); 1060 } catch(Exception e) { 1061 return DesktopFileItem.init; 1062 } 1063 } 1064 return DesktopFileItem.init; 1065 } 1066 1067 string findDesktopFilePath(string desktopId, out string dir) 1068 { 1069 foreach(baseDir; _baseDirs) { 1070 auto filePath = findDesktopFile(desktopId, only(baseDir)); 1071 if (filePath.length) { 1072 dir = baseDir; 1073 return filePath; 1074 } 1075 } 1076 return null; 1077 } 1078 1079 DesktopFileItem[string] _cache; 1080 string[] _baseDirs; 1081 DesktopFile.DesktopReadOptions _readOptions; 1082 string[] _binPaths; 1083 } 1084 1085 private enum FindAssocFlag { 1086 none = 0, 1087 onlyFirst = 1, 1088 ignoreRemovedGroup = 2 1089 } 1090 1091 private string[] listAssociatedApplicationsImpl(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, FindAssocFlag flag = FindAssocFlag.none) 1092 { 1093 string[] removed; 1094 string[] desktopIds; 1095 1096 foreach(mimeAppsListFile; mimeAppsListFiles) { 1097 if (mimeAppsListFile is null) { 1098 continue; 1099 } 1100 1101 auto removedAppsGroup = mimeAppsListFile.removedAssociations(); 1102 if (removedAppsGroup !is null && !(flag & FindAssocFlag.ignoreRemovedGroup)) { 1103 removed ~= removedAppsGroup.appsForMimeType(mimeType).array; 1104 } 1105 auto addedAppsGroup = mimeAppsListFile.addedAssociations(); 1106 if (addedAppsGroup !is null) { 1107 foreach(desktopId; addedAppsGroup.appsForMimeType(mimeType)) { 1108 if (removed.canFind(desktopId) || desktopIds.canFind(desktopId)) { 1109 continue; 1110 } 1111 desktopIds ~= desktopId; 1112 } 1113 } 1114 } 1115 1116 foreach(mimeInfoCacheFile; mimeInfoCacheFiles) { 1117 if (mimeInfoCacheFile is null) { 1118 continue; 1119 } 1120 1121 foreach(desktopId; mimeInfoCacheFile.appsForMimeType(mimeType)) { 1122 if (removed.canFind(desktopId) || desktopIds.canFind(desktopId)) { 1123 continue; 1124 } 1125 desktopIds ~= desktopId; 1126 } 1127 } 1128 1129 return desktopIds; 1130 } 1131 1132 private const(DesktopFile)[] findAssociatedApplicationsImpl(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider, FindAssocFlag flag = FindAssocFlag.none) 1133 { 1134 const(DesktopFile)[] desktopFiles; 1135 foreach(desktopId; listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, flag)) { 1136 auto desktopFile = desktopFileProvider.getByDesktopId(desktopId); 1137 if (desktopFile && desktopFile.desktopEntry.escapedValue("Exec").length != 0) { 1138 if (flag & FindAssocFlag.onlyFirst) { 1139 return [desktopFile]; 1140 } 1141 desktopFiles ~= desktopFile; 1142 } 1143 } 1144 return desktopFiles; 1145 } 1146 /** 1147 * List associated applications for given MIME type. 1148 * Params: 1149 * mimeType = MIME type or uri scheme handler in question. 1150 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1151 * mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching. 1152 * Returns: Array of desktop ids of applications that are capable of opening of given MIME type or url of given scheme. 1153 * Note: It does not check if returned desktop ids point to valid .desktop files. 1154 * See_Also: $(D findAssociatedApplications) 1155 */ 1156 string[] listAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles) if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 1157 && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile))) 1158 { 1159 return listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles); 1160 } 1161 1162 /** 1163 * List all known associated applications for given MIME type, including explicitly removed by user. 1164 * Params: 1165 * mimeType = MIME type or uri scheme handler in question. 1166 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1167 * mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching. 1168 * Returns: Array of desktop ids of applications that are capable of opening of given MIME type or url of given scheme. 1169 * Note: It does not check if returned desktop ids point to valid .desktop files. 1170 * See_Also: $(D listAssociatedApplications), $(D findKnownAssociatedApplications) 1171 */ 1172 string[] listKnownAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles) 1173 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 1174 && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile))) 1175 { 1176 return listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, FindAssocFlag.ignoreRemovedGroup); 1177 } 1178 1179 /** 1180 * List explicitily set default applications for given MIME type. 1181 * Params: 1182 * mimeType = MIME type or uri scheme handler in question. 1183 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1184 * Returns: Array of desktop ids of default applications. 1185 * Note: It does not check if returned desktop ids point to valid .desktop files. 1186 * See_Also: $(D findDefaultApplication) 1187 */ 1188 string[] listDefaultApplications(ListRange)(string mimeType, ListRange mimeAppsListFiles) 1189 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))) 1190 { 1191 string[] defaultApplications; 1192 foreach(mimeAppsListFile; mimeAppsListFiles) { 1193 if (mimeAppsListFile is null) { 1194 continue; 1195 } 1196 auto defaultAppsGroup = mimeAppsListFile.defaultApplications(); 1197 if (defaultAppsGroup !is null) { 1198 foreach(desktopId; defaultAppsGroup.appsForMimeType(mimeType)) { 1199 defaultApplications ~= desktopId; 1200 } 1201 } 1202 } 1203 return defaultApplications; 1204 } 1205 1206 /** 1207 * Find associated applications for given MIME type. 1208 * Params: 1209 * mimeType = MIME type or uri scheme handler in question. 1210 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1211 * mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching. 1212 * desktopFileProvider = Desktop file provider instance. Must be non-null. 1213 * Returns: Array of found $(D desktopfile.file.DesktopFile) objects capable of opening file of given MIME type or url of given scheme. 1214 * Note: If no applications found for this mimeType, you may consider to use this function on parent MIME type. 1215 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s04.html, Adding/removing associations), $(D findKnownAssociatedApplications), $(D listAssociatedApplications) 1216 */ 1217 const(DesktopFile)[] findAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider) 1218 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 1219 && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile))) 1220 in { 1221 assert(desktopFileProvider !is null); 1222 } 1223 body { 1224 return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider); 1225 } 1226 1227 /// 1228 version(mimeappsFileTest) unittest 1229 { 1230 auto desktopProvider = new DesktopFileProvider(["test/applications"]); 1231 auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list"); 1232 auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache"); 1233 assert(findAssociatedApplications("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider) 1234 .map!(d => d.displayName()).equal(["Geany", "Kate", "Emacs"])); 1235 assert(findAssociatedApplications("application/nonexistent", [mimeAppsList], [mimeInfoCache], desktopProvider).length == 0); 1236 assert(findAssociatedApplications("application/x-data", [mimeAppsList], [mimeInfoCache], desktopProvider).length == 0); 1237 } 1238 1239 /** 1240 * Find all known associated applications for given MIME type, including explicitly removed by user. 1241 * Params: 1242 * mimeType = MIME type or uri scheme handler in question. 1243 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1244 * mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching. 1245 * desktopFileProvider = Desktop file provider instance. Must be non-null. 1246 * Returns: Array of found $(D desktopfile.file.DesktopFile) objects capable of opening file of given MIME type or url of given scheme. 1247 * See_Also: $(D findAssociatedApplications), $(D listKnownAssociatedApplications) 1248 */ 1249 const(DesktopFile)[] findKnownAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider) 1250 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 1251 && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile))) 1252 in { 1253 assert(desktopFileProvider !is null); 1254 } 1255 body { 1256 return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.ignoreRemovedGroup); 1257 } 1258 1259 /// 1260 version(mimeappsFileTest) unittest 1261 { 1262 auto desktopProvider = new DesktopFileProvider(["test/applications"]); 1263 auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list"); 1264 auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache"); 1265 assert(findKnownAssociatedApplications("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider) 1266 .map!(d => d.displayName()).equal(["Geany", "Kate", "Emacs", "Okular"])); 1267 } 1268 1269 /** 1270 * Find default application for given MIME type. 1271 * Params: 1272 * mimeType = MIME type or uri scheme handler in question. 1273 * mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching. 1274 * mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching. 1275 * desktopFileProvider = Desktop file provider instance. Must be non-null. 1276 * Returns: Found $(D desktopfile.file.DesktopFile) or null if not found. 1277 * Note: You may consider calling this function on parent MIME types (recursively until application is found) if it fails for the original mimeType. 1278 * Retrieving parent MIME types is out of the scope of this library. $(LINK2 https://github.com/FreeSlave/mime, MIME library) is available for that purpose. 1279 * Check the $(LINK2 https://github.com/FreeSlave/mimeapps/blob/master/examples/open.d, Open example). 1280 * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s03.html, Default Application) 1281 */ 1282 const(DesktopFile) findDefaultApplication(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider) 1283 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 1284 && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile))) 1285 in { 1286 assert(desktopFileProvider !is null); 1287 } 1288 body { 1289 foreach(desktopId; listDefaultApplications(mimeType, mimeAppsListFiles)) { 1290 auto desktopFile = desktopFileProvider.getByDesktopId(desktopId); 1291 if (desktopFile !is null && desktopFile.desktopEntry.escapedValue("Exec").length != 0) { 1292 return desktopFile; 1293 } 1294 } 1295 1296 auto desktopFiles = findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.onlyFirst); 1297 return desktopFiles.length ? desktopFiles.front : null; 1298 } 1299 1300 /// 1301 version(mimeappsFileTest) unittest 1302 { 1303 auto desktopProvider = new DesktopFileProvider(["test/applications"]); 1304 auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list"); 1305 auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache"); 1306 assert(findDefaultApplication("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider).displayName() == "Geany"); 1307 assert(findDefaultApplication("image/png", [mimeAppsList], [mimeInfoCache], desktopProvider).displayName() == "Gwenview"); 1308 assert(findDefaultApplication("application/pdf", [mimeAppsList], [mimeInfoCache], desktopProvider).displayName() == "Okular"); 1309 assert(findDefaultApplication("application/nonexistent", [mimeAppsList], [mimeInfoCache], desktopProvider) is null); 1310 assert(findDefaultApplication("application/x-data", [mimeAppsList], [mimeInfoCache], desktopProvider) is null); 1311 } 1312 1313 /** 1314 * Struct used for construction of file assocation update query. 1315 * This allows to reuse the same query many times or for many mimeapps.list files. 1316 */ 1317 struct AssociationUpdateQuery 1318 { 1319 // Operation on MIME type application assocication. 1320 private static struct Operation 1321 { 1322 // Type of operation 1323 enum Type : ubyte { 1324 add, 1325 remove, 1326 setDefault, 1327 setAdded 1328 } 1329 1330 string mimeType; 1331 string desktopId; 1332 Type type; 1333 } 1334 1335 /** 1336 * See_Also: $(D MimeAppsListFile.addAssociation) 1337 */ 1338 @safe ref typeof(this) addAssociation(string mimeType, string desktopId) return nothrow 1339 { 1340 _operations ~= Operation(mimeType, desktopId, Operation.Type.add); 1341 return this; 1342 } 1343 /** 1344 * See_Also: $(D MimeAppsListFile.setAddedAssocations) 1345 */ 1346 @safe ref typeof(this) setAddedAssocations(Range)(string mimeType, Range desktopIds) return if (isInputRange!Range && is(ElementType!Range : string)) 1347 { 1348 _operations ~= Operation(mimeType, MimeAppsGroup.joinApps(desktopIds), Operation.Type.setAdded); 1349 return this; 1350 } 1351 /** 1352 * See_Also: $(D MimeAppsListFile.removeAssociation) 1353 */ 1354 @safe ref typeof(this) removeAssociation(string mimeType, string desktopId) return nothrow 1355 { 1356 _operations ~= Operation(mimeType, desktopId, Operation.Type.remove); 1357 return this; 1358 } 1359 /** 1360 * See_Also: $(D MimeAppsListFile.setDefaultApplication) 1361 */ 1362 @safe ref typeof(this) setDefaultApplication(string mimeType, string desktopId) return nothrow 1363 { 1364 _operations ~= Operation(mimeType, desktopId, Operation.Type.setDefault); 1365 return this; 1366 } 1367 1368 /** 1369 * Apply query to $(D MimeAppsListFile). 1370 */ 1371 @safe void apply(scope MimeAppsListFile file) const 1372 { 1373 foreach(op; _operations) 1374 { 1375 final switch(op.type) 1376 { 1377 case Operation.Type.add: 1378 file.addAssociation(op.mimeType, op.desktopId); 1379 break; 1380 case Operation.Type.remove: 1381 file.removeAssociation(op.mimeType, op.desktopId); 1382 break; 1383 case Operation.Type.setDefault: 1384 file.setDefaultApplication(op.mimeType, op.desktopId); 1385 break; 1386 case Operation.Type.setAdded: 1387 file.setAddedAssocations(op.mimeType, MimeAppsGroup.splitApps(op.desktopId)); 1388 break; 1389 } 1390 } 1391 } 1392 private: 1393 Operation[] _operations; 1394 } 1395 1396 /// 1397 unittest 1398 { 1399 AssociationUpdateQuery query; 1400 query.addAssociation("text/plain", "geany.desktop"); 1401 query.removeAssociation("text/plain", "kde4-okular.desktop"); 1402 query.setDefaultApplication("text/plain", "kde4-kate.desktop"); 1403 query.setAddedAssocations("image/png", ["kde4-gwenview.desktop", "gthumb.desktop"]); 1404 1405 auto file = new MimeAppsListFile(); 1406 query.apply(file); 1407 file.addedAssociations().appsForMimeType("text/plain").equal(["kde4-kate.desktop", "geany.desktop"]); 1408 file.defaultApplications().appsForMimeType("text/plain").equal(["kde4-kate.desktop"]); 1409 file.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"]); 1410 file.addedAssociations().appsForMimeType("image/png").equal(["kde4-gwenview.desktop", "gthumb.desktop"]); 1411 } 1412 1413 /** 1414 * Apply query for file with fileName. This should be mimeapps.list file. 1415 * If file does not exist it will be created. 1416 * Throws: 1417 * $(D inilike.file.IniLikeReadException) if errors occured during the file reading. 1418 * $(B ErrnoException) if errors occured during the file writing. 1419 */ 1420 @trusted void updateAssociations(string fileName, ref scope const AssociationUpdateQuery query) 1421 { 1422 MimeAppsListFile file; 1423 if (fileName.exists) { 1424 file = new MimeAppsListFile(fileName); 1425 } else { 1426 file = new MimeAppsListFile(); 1427 } 1428 query.apply(file); 1429 file.saveToFile(fileName); 1430 } 1431 1432 static if (isFreedesktop) 1433 { 1434 /** 1435 * Change MIME Applications Associations for the current user by applying the provided query. 1436 * 1437 * For compatibility purposes it overwrites user overrides in the deprecated location too. 1438 * 1439 * $(BLUE This function is Freedesktop only). 1440 * Note: It will not overwrite desktop-specific overrides. 1441 * See_Also: $(D userMimeAppsListPaths), $(D userAppDataMimeAppsListPaths) 1442 */ 1443 @safe void updateAssociations(ref scope const AssociationUpdateQuery query) 1444 { 1445 foreach(fileName; userMimeAppsListPaths(string[].init) ~ userAppDataMimeAppsListPaths(string[].init)) { 1446 updateAssociations(fileName, query); 1447 } 1448 } 1449 }