Skip to content

Commit

Permalink
Open With apps for macOS - better duti cmd handling and caching
Browse files Browse the repository at this point in the history
  • Loading branch information
pskowronek committed May 3, 2023
1 parent c7723a3 commit 5f2d06c
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* This file is part of muCommander, http://www.mucommander.com
*
* muCommander is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* muCommander is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.mucommander.command;

import javax.swing.Icon;

/**
* Extended version of Command that also keeps optional icon.
*/
public class CommandExtended extends Command {

private final Icon icon;

public CommandExtended(String alias, String command, CommandType type, String displayName, Icon icon ) {
super(alias, command, type, displayName);
this.icon = icon;
}

/**
* Returns an icon associated with this command.
* @return an icon, or null;
*/
public Icon getIcon() {
return icon;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/**
* Simple structure of two elements.
*
* Note that this class does not support concurrent usage, and does not exposes setters and
* Note that this class does not support concurrent usage, and does not expose setters and
* getters methods, on purpose in order to expose simple API similar to C++ pair structure.
*
* @author Arik Hadas
Expand Down
5 changes: 3 additions & 2 deletions mucommander-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ jar {
'com.mucommander.snapshot,' +
'com.mucommander.search,' +
'com.mucommander.text,' +
'com.mucommander.ui.viewer,' +
'com.mucommander.ui.icon,' +
'com.mucommander.ui.main,' +
'com.mucommander.ui.main.table',
'com.mucommander.ui.main.table,' +
'com.mucommander.ui.viewer',
'Bundle-Activator': 'com.mucommander.Activator')
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,15 @@
package com.mucommander.ui.main.menu;

import javax.swing.Action;
import javax.swing.Icon;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JSeparator;

import com.mucommander.command.Command;
import com.mucommander.command.CommandExtended;
import com.mucommander.command.CommandManager;
import com.mucommander.command.CommandType;
import com.mucommander.commons.file.AbstractFile;
import com.mucommander.commons.file.FileFactory;
import com.mucommander.commons.util.ui.helper.MenuToolkit;
import com.mucommander.core.desktop.DesktopManager;
import com.mucommander.process.ProcessRunner;
Expand All @@ -36,7 +35,6 @@
import com.mucommander.ui.action.ActionManager;
import com.mucommander.ui.action.MuAction;
import com.mucommander.ui.action.impl.CommandAction;
import com.mucommander.ui.icon.FileIcons;
import com.mucommander.ui.main.MainFrame;

import java.io.IOException;
Expand Down Expand Up @@ -105,9 +103,10 @@ private synchronized void populate() { // why synchronized btw?
for (Command cmd : commands) {
MuAction action = createMuAction(cmd);
action.setLabel(cmd.getDisplayName());
// TODO - add Icon to Command? to avoid building the path here....
Icon icon = FileIcons.getFileIcon(FileFactory.getFile("/Applications/" + cmd.getDisplayName() + ".app"));
add(action).setIcon(icon);
// TODO there's sth wrong with java level settings for this subsystem in IDE
if (cmd instanceof CommandExtended) {
add(action).setIcon(((CommandExtended)cmd).getIcon());
}
if (separateDefault) {
add(new JSeparator());
separateDefault = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Predicate;

import javax.swing.Icon;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JTabbedPane;
Expand All @@ -49,9 +51,11 @@
import com.dd.plist.PropertyListFormatException;
import com.mucommander.command.Command;
import com.mucommander.command.CommandException;
import com.mucommander.command.CommandExtended;
import com.mucommander.command.CommandManager;
import com.mucommander.command.CommandType;
import com.mucommander.commons.file.AbstractFile;
import com.mucommander.commons.file.FileFactory;
import com.mucommander.commons.file.protocol.local.LocalFile;
import com.mucommander.commons.runtime.OsFamily;
import com.mucommander.commons.util.Pair;
Expand All @@ -63,6 +67,7 @@
import com.mucommander.desktop.TrashProvider;
import com.mucommander.os.notifier.AbstractNotifier;
import com.mucommander.text.Translator;
import com.mucommander.ui.icon.FileIcons;
import com.mucommander.ui.macos.AppleScript;
import com.mucommander.ui.macos.OSXIntegration;
import com.mucommander.ui.macos.TabbedPaneUICustomizer;
Expand All @@ -85,11 +90,18 @@ public class OSXDesktopAdapter extends DefaultDesktopAdapter {

private static final String DEFAULT_SHELL_INTERACTIVE = "--login";

// cached values
private String dutiCmdPath = null;

private Map<String, List<Command>> openWithCommands = new HashMap<>();

/** The key of the comment attribute in file metadata */
public static final String COMMENT_PROPERTY_NAME = "com.apple.metadata:kMDItemFinderComment";
public static final String TAGS_PROPERTY_NAME = "com.apple.metadata:_kMDItemUserTags";

public String toString() {return "macOS Desktop";}
public String toString() {
return "macOS Desktop";
}

@Override
public boolean isAvailable() {return OsFamily.getCurrent().equals(OsFamily.MAC_OS);}
Expand Down Expand Up @@ -249,84 +261,110 @@ private String getMacOsUserShell() {
@Override
public boolean isOpenWithAvailable() {
AtomicBoolean result = new AtomicBoolean(false);
runCommand(new String[]{"duti", "-h"}, true,1, s -> {
var dutiCmdPath = getPathOfDutiCmd();
if (dutiCmdPath == null) {
return result.get();
}
// additional checking if 'duty' a) works b) is what it should be
runCommand(new String[]{dutiCmdPath, "-h"}, true,1, s -> {
// a simple sanity check of 'duti' command output
if (s.contains("bundle_id")) {
result.set(true);
return true; // we're good, no further searching needed
}
return false; // continue searching
});
LOGGER.info("Command 'duti' found in the system? {}", result);
LOGGER.error("Command 'duti' found in the system? {}", result);
return result.get();
}

@Override
public List<Command> getCommandsForOpenWith(AbstractFile file) {
List<Command> result = new ArrayList<>();
Set<Command> sorted = new TreeSet<>(Comparator.comparing(o -> o.getDisplayName().toLowerCase()));
var dutiCmdPath = getPathOfDutiCmd();
var ext = file.getExtension();
if (ext != null && !ext.isEmpty()) {
runCommand(new String[]{"duti", "-e", ext}, false,0, s -> {
String typeIdentifier = "UTTypeIdentifier = ";
int idx = s.indexOf(typeIdentifier);
if (idx >= 0) {
String uti = s.substring(idx + typeIdentifier.length()).trim();
if (!uti.isEmpty()) {
for (String bundleId : getAppBundleIdsForUTI(uti)) {
// Tried to fight with quotes around such bundle ids in Command and ProcessBuilder,
// but I finally lost my patience - probably due to:
// Command behavior: "It is important to remember that <code>"</code> characters are <b>not</b> removed from the resulting tokens."
if (bundleId.contains(" ")) {
LOGGER.error("Going to ignore {} as it contains spaces...", bundleId);
continue;
}
String appName = getAppNameForBundleId(bundleId);
Command cmd = new Command(
appName,
"open -b " + bundleId + " $f",
CommandType.NORMAL_COMMAND,
appName
);
// default is first, the rest is alpha sorted (mimicking Finder behavior)
if (result.isEmpty()) {
result.add(cmd);
} else {
sorted.add(cmd);
}
if (dutiCmdPath == null || ext == null || ext.isEmpty()) {
return result;
}
// using cache (probably it would be nice to have time-bound cache (guava, apache?) or size limited
if (openWithCommands.containsKey(ext)) {
return openWithCommands.get(ext);
}
Set<Command> sorted = new TreeSet<>(Comparator.comparing(o -> o.getDisplayName().toLowerCase()));

runCommand(new String[]{dutiCmdPath, "-e", ext}, false,0, s -> {
String typeIdentifier = "UTTypeIdentifier = ";
int idx = s.indexOf(typeIdentifier);
if (idx >= 0) {
String uti = s.substring(idx + typeIdentifier.length()).trim();
if (!uti.isEmpty()) {
for (String bundleId : getAppBundleIdsForUTI(uti)) {
// Tried to fight with quotes around such bundle ids in Command and ProcessBuilder,
// but I finally lost my patience - probably due to:
// Command behavior: "It is important to remember that <code>"</code> characters are <b>not</b> removed from the resulting tokens."
if (bundleId.contains(" ")) {
LOGGER.error("Going to ignore {} as it contains spaces...", bundleId);
continue;
}
Pair<String, String> appPair = getAppNameAndPathForBundleId(bundleId);
String appName = appPair.first;
Command cmd = new CommandExtended(
appName,
"open -b " + bundleId + " $f",
CommandType.NORMAL_COMMAND,
appName,
appPair.second != null ? FileIcons.getFileIcon(FileFactory.getFile(appPair.second)) : null
);
// default is the first, the rest is alpha sorted (mimicking Finder behavior)
if (result.isEmpty()) {
result.add(cmd);
} else {
sorted.add(cmd);
}

}
return true; // we're good, no further searching needed
}
return false; // continue searching
});
}
return true; // we're good, no further searching needed
}
return false; // continue searching
});

result.addAll(sorted);
LOGGER.error("For file: {} found the following commands: {}", file, result);
openWithCommands.put(ext, result);
LOGGER.info("For file: {} found the following commands: {}", file, result);
return result;
}

private String getAppNameForBundleId(String bundleId) {
StringBuilder result = new StringBuilder();
/**
* Method tries to find app name and its path from a given bundle id.
* @param bundleId the bundle id
* @return a pair, first is app name, second is its path (this can be null)
*/
private Pair<String, String> getAppNameAndPathForBundleId(String bundleId) {
Pair result = new Pair();
runCommand(new String[]{"mdfind", "kMDItemCFBundleIdentifier", "=", bundleId}, false,0, s -> {
// a simple sanity check whether it is bundle id
if (s.endsWith(".app")) {
result.append(s, s.lastIndexOf("/") + 1, s.lastIndexOf(".app"));
result.first = s.substring(s.lastIndexOf("/") + 1, s.lastIndexOf(".app"));
result.second = s;
return true; // we're good, no further searching needed
}
return false; // continue searching
});
if (result.length() == 0) {
// if mdfind didn't work, try silly conversion of bundle id to app name
result.append(StringUtils.capitalize(bundleId.substring(bundleId.lastIndexOf(".") + 1)));
if (result.first == null) {
// if mdfind way didn't work, try silly conversion of bundle id to app name
result.first = StringUtils.capitalize(bundleId.substring(bundleId.lastIndexOf(".") + 1));
}
return result.toString();
return result;
}

private List<String> getAppBundleIdsForUTI(String uti) {
List<String> result = new ArrayList<>();
runCommand(new String[]{"duti", "-l", uti}, false,0, s -> {
var dutiCmdPath = getPathOfDutiCmd();
if (dutiCmdPath == null) {
return result;
}
runCommand(new String[]{dutiCmdPath, "-l", uti}, false,0, s -> {
// a simple sanity check whether it is bundle id
if (s.contains(".")) {
result.add(s.trim());
Expand All @@ -337,6 +375,30 @@ private List<String> getAppBundleIdsForUTI(String uti) {
return result;
}

/**
* Tries to locate 'duty' command by invoking 'which', the result is cached.
* @return the path of 'duty' command, or null if not located
*/
private String getPathOfDutiCmd() {
if (dutiCmdPath != null) {
return dutiCmdPath;
}
runCommand(new String[]{getMacOsUserShell(), "-l", "-c", "which duti"}, false,0, s -> {
// a simple sanity check of 'duti' command output
if (s.contains("duti")) {
dutiCmdPath = s;
return true; // we're good, no further searching needed
}
return false; // continue searching
});
if (dutiCmdPath != null && dutiCmdPath.length() > 0) {
LOGGER.info("Command 'duti' found here: {}", dutiCmdPath);
} else {
LOGGER.error("Command 'duti' not found");
}
return dutiCmdPath;
}

/**
* A helper method to run a command with parameters.
* @param commands command and its parameters
Expand Down

0 comments on commit 5f2d06c

Please sign in to comment.