Skip to content

Commit c7156f1

Browse files
authored
Merge pull request #85 from cryptomator/feature/quick-access-dolphin
Feature: Implementation of Quick Access API for KDE Dolphin
2 parents de95ce2 + 11d5bbd commit c7156f1

File tree

5 files changed

+445
-0
lines changed

5 files changed

+445
-0
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package org.cryptomator.linux.quickaccess;
2+
3+
import org.cryptomator.integrations.common.CheckAvailability;
4+
import org.cryptomator.integrations.common.DisplayName;
5+
import org.cryptomator.integrations.common.OperatingSystem;
6+
import org.cryptomator.integrations.common.Priority;
7+
import org.cryptomator.integrations.quickaccess.QuickAccessService;
8+
import org.cryptomator.integrations.quickaccess.QuickAccessServiceException;
9+
import org.xml.sax.SAXException;
10+
11+
import javax.xml.XMLConstants;
12+
import javax.xml.transform.Source;
13+
import javax.xml.transform.stream.StreamSource;
14+
import javax.xml.validation.SchemaFactory;
15+
import javax.xml.validation.Validator;
16+
import java.io.IOException;
17+
import java.io.StringReader;
18+
import java.nio.charset.StandardCharsets;
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.nio.file.StandardCopyOption;
22+
import java.nio.file.StandardOpenOption;
23+
import java.util.List;
24+
import java.util.UUID;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.locks.Lock;
27+
import java.util.concurrent.locks.ReentrantLock;
28+
29+
/**
30+
* Implemenation of the {@link QuickAccessService} for KDE desktop environments using Dolphin file browser.
31+
*/
32+
@DisplayName("KDE Dolphin Places")
33+
@OperatingSystem(OperatingSystem.Value.LINUX)
34+
@Priority(90)
35+
public class DolphinPlaces implements QuickAccessService {
36+
37+
private static final int MAX_FILE_SIZE = 1 << 15; //xml is quite verbose
38+
private static final Path PLACES_FILE = Path.of(System.getProperty("user.home"), ".local/share/user-places.xbel");
39+
private static final Path TMP_FILE = Path.of(System.getProperty("java.io.tmpdir"), "user-places.xbel.cryptomator.tmp");
40+
private static final Lock MODIFY_LOCK = new ReentrantLock();
41+
private static final String ENTRY_TEMPLATE = """
42+
<bookmark href="%s">
43+
<title>%s</title>
44+
<info>
45+
<metadata owner="http://freedesktop.org">
46+
<bookmark:icon name="drive-harddisk-encrypted"/>
47+
</metadata>
48+
<metadata owner="https://cryptomator.org">
49+
<id>%s</id>
50+
</metadata>
51+
</info>
52+
</bookmark>""";
53+
54+
55+
private static final Validator XML_VALIDATOR;
56+
57+
static {
58+
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
59+
try (var schemaDefinition = DolphinPlaces.class.getResourceAsStream("/xbel-1.0.xsd")) {
60+
Source schemaFile = new StreamSource(schemaDefinition);
61+
XML_VALIDATOR = factory.newSchema(schemaFile).newValidator();
62+
} catch (IOException | SAXException e) {
63+
throw new IllegalStateException("Failed to load included XBEL schema definition file.", e);
64+
}
65+
}
66+
67+
68+
@Override
69+
public QuickAccessService.QuickAccessEntry add(Path target, String displayName) throws QuickAccessServiceException {
70+
String id = UUID.randomUUID().toString();
71+
try {
72+
MODIFY_LOCK.lock();
73+
if (Files.size(PLACES_FILE) > MAX_FILE_SIZE) {
74+
throw new IOException("File %s exceeds size of %d bytes".formatted(PLACES_FILE, MAX_FILE_SIZE));
75+
}
76+
var placesContent = Files.readString(PLACES_FILE);
77+
//validate
78+
XML_VALIDATOR.validate(new StreamSource(new StringReader(placesContent)));
79+
// modify
80+
int insertIndex = placesContent.lastIndexOf("</xbel"); //cannot be -1 due to validation; we do not match the end tag, since betweent tag name and closing bracket can be whitespaces
81+
try (var writer = Files.newBufferedWriter(TMP_FILE, StandardCharsets.UTF_8, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
82+
writer.write(placesContent, 0, insertIndex);
83+
writer.newLine();
84+
writer.write(ENTRY_TEMPLATE.formatted(target.toUri(), displayName, id).indent(1));
85+
writer.newLine();
86+
writer.write(placesContent, insertIndex, placesContent.length() - insertIndex);
87+
}
88+
// save
89+
Files.move(TMP_FILE, PLACES_FILE, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
90+
return new DolphinPlacesEntry(id);
91+
} catch (SAXException | IOException e) {
92+
throw new QuickAccessServiceException("Adding entry to KDE places file failed.", e);
93+
} finally {
94+
MODIFY_LOCK.unlock();
95+
}
96+
}
97+
98+
private static class DolphinPlacesEntry implements QuickAccessEntry {
99+
100+
private final String id;
101+
private volatile boolean isRemoved = false;
102+
103+
DolphinPlacesEntry(String id) {
104+
this.id = id;
105+
}
106+
107+
@Override
108+
public void remove() throws QuickAccessServiceException {
109+
try {
110+
MODIFY_LOCK.lock();
111+
if (isRemoved) {
112+
return;
113+
}
114+
if (Files.size(PLACES_FILE) > MAX_FILE_SIZE) {
115+
throw new IOException("File %s exceeds size of %d bytes".formatted(PLACES_FILE, MAX_FILE_SIZE));
116+
}
117+
var placesContent = Files.readString(PLACES_FILE);
118+
int idIndex = placesContent.lastIndexOf(id);
119+
if (idIndex == -1) {
120+
isRemoved = true;
121+
return; //we assume someone has removed our entry
122+
}
123+
//validate
124+
XML_VALIDATOR.validate(new StreamSource(new StringReader(placesContent)));
125+
//modify
126+
int openingTagIndex = indexOfEntryOpeningTag(placesContent, idIndex);
127+
var contentToWrite1 = placesContent.substring(0, openingTagIndex).stripTrailing();
128+
129+
int closingTagEndIndex = placesContent.indexOf('>', placesContent.indexOf("</bookmark", idIndex));
130+
var part2Tmp = placesContent.substring(closingTagEndIndex + 1).split("\\A\\v+", 2); //removing leading vertical whitespaces, but no indentation
131+
var contentToWrite2 = part2Tmp[part2Tmp.length - 1];
132+
133+
try (var writer = Files.newBufferedWriter(TMP_FILE, StandardCharsets.UTF_8, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
134+
writer.write(contentToWrite1);
135+
writer.newLine();
136+
writer.write(contentToWrite2);
137+
}
138+
// save
139+
Files.move(TMP_FILE, PLACES_FILE, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
140+
isRemoved = true;
141+
} catch (IOException | SAXException e) {
142+
throw new QuickAccessServiceException("Removing entry from KDE places file failed.", e);
143+
} finally {
144+
MODIFY_LOCK.unlock();
145+
}
146+
}
147+
148+
private int indexOfEntryOpeningTag(String placesContent, int idIndex) {
149+
var xmlWhitespaceChars = List.of(' ', '\t', '\n');
150+
for (char c : xmlWhitespaceChars) {
151+
int idx = placesContent.lastIndexOf("<bookmark" + c, idIndex);
152+
if (idx != -1) {
153+
return idx;
154+
}
155+
}
156+
throw new IllegalStateException("File " + PLACES_FILE + " is valid xbel file, but does not contain opening bookmark tag.");
157+
}
158+
}
159+
160+
@CheckAvailability
161+
public static boolean isSupported() {
162+
try {
163+
var nautilusExistsProc = new ProcessBuilder().command("test", "`command -v dolphin`").start();
164+
if (nautilusExistsProc.waitFor(5000, TimeUnit.MILLISECONDS)) {
165+
return nautilusExistsProc.exitValue() == 0;
166+
}
167+
} catch (IOException | InterruptedException e) {
168+
//NO-OP
169+
}
170+
return false;
171+
}
172+
}

src/main/resources/xbel-1.0.dtd

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<!-- This is the XML Bookmarks Exchange Language, version 1.0. It
2+
should be used with the formal public identifier:
3+
4+
+//IDN python.org//DTD XML Bookmark Exchange Language 1.0//EN//XML
5+
6+
One valid system identifier at which this DTD will remain
7+
available is:
8+
9+
http://pyxml.sourceforge.net/topics/dtds/xbel-1.0.dtd
10+
11+
More information on the DTD, including reference
12+
documentation, is available at:
13+
14+
http://www.python.org/topics/xml/xbel/
15+
16+
Attributes which take date/time values should encode the value
17+
according to the W3C NOTE on date/time formats:
18+
19+
http://www.w3.org/TR/NOTE-datetime
20+
-->
21+
22+
23+
<!-- Customization entities. Define these before "including" this DTD
24+
to create "subclassed" DTDs.
25+
-->
26+
<!ENTITY % local.node.att "">
27+
<!ENTITY % local.url.att "">
28+
<!ENTITY % local.nodes.mix "">
29+
30+
<!ENTITY % node.att "id ID #IMPLIED
31+
added CDATA #IMPLIED
32+
%local.node.att;">
33+
34+
<!ENTITY % url.att "href CDATA #REQUIRED
35+
visited CDATA #IMPLIED
36+
modified CDATA #IMPLIED
37+
%local.url.att;">
38+
39+
<!ENTITY % nodes.mix "bookmark|folder|alias|separator
40+
%local.nodes.mix;">
41+
42+
43+
<!ELEMENT xbel (title?, info?, desc?, (%nodes.mix;)*)>
44+
<!ATTLIST xbel
45+
%node.att;
46+
version CDATA #FIXED "1.0"
47+
>
48+
<!ELEMENT title (#PCDATA)>
49+
50+
<!--=================== Info ======================================-->
51+
52+
<!ELEMENT info (metadata+)>
53+
54+
<!ELEMENT metadata EMPTY>
55+
<!ATTLIST metadata
56+
owner CDATA #REQUIRED
57+
>
58+
59+
<!--=================== Folder ====================================-->
60+
61+
<!ELEMENT folder (title?, info?, desc?, (%nodes.mix;)*)>
62+
<!ATTLIST folder
63+
%node.att;
64+
folded (yes|no) 'yes'
65+
>
66+
67+
<!--=================== Bookmark ==================================-->
68+
69+
<!ELEMENT bookmark (title?, info?, desc?)>
70+
<!ATTLIST bookmark
71+
%node.att;
72+
%url.att;
73+
>
74+
75+
<!ELEMENT desc (#PCDATA)>
76+
77+
<!--=================== Separator =================================-->
78+
79+
<!ELEMENT separator EMPTY>
80+
81+
<!--=================== Alias =====================================-->
82+
83+
<!-- <alias> elements correspond to Netscape bookmark aliases. The
84+
required "ref" attribute must refer to a <bookmark> or <folder>
85+
element. Note that MSIE aliases can refer to folders, so that is
86+
supported in XBEL. Applications must be careful about traversing
87+
aliases to folders to avoid improper recursion through circular
88+
data structures.
89+
-->
90+
91+
<!ELEMENT alias EMPTY>
92+
<!ATTLIST alias
93+
ref IDREF #REQUIRED
94+
>

0 commit comments

Comments
 (0)