45. Desktop Integration¶
A desktop program is not alone: it hands links to the web browser and files to the programs the user chose for them, tells the window manager that something is starting, keeps passwords where the desktop keeps secrets, puts pictures on the clipboard and reads the user's address books. This chapter covers the parts of amade-commons that talk to the desktop around the program.
Opening links and files: DesktopLauncher¶
Package desktop · introduced in 11. Web · source: DesktopLauncher
What it is for. Opening a link in the user's web browser and a file in the program the user chose for its type. Web opens links that leave the documentation, Mail and Calendar open links in messages and appointments, Files opens every file the user double-clicks.
| Method | What it does |
|---|---|
browse(uri) |
Opens a link in the web browser |
open(path) |
Opens a file in its program |
DesktopLauncher.isRisky(fileName) |
Whether opening the file could run code (below) |
When to choose it. Whenever something leaves the program. CommonsModule binds one, so a window asks for it in its constructor; a test binds a subclass that only records what it was asked to open. Call it on the background executor: starting another program can take a moment.
How it works. xdg-open first, the command every Linux desktop has for "open this with the user's choice". Java's own java.awt.Desktop is the fallback, because on Linux it needs GNOME's libraries, which amadeOS does not have, while on Windows and macOS it is the right way:
public void browse(String uri) throws IOException {
LaunchFeedback.announce();
if (!xdgOpen(uri)) {
if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
throw new IOException("No web browser available");
}
Desktop.getDesktop().browse(URI.create(uri));
}
}
private static boolean xdgOpen(String target) {
try {
new ProcessBuilder("xdg-open", target).redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
return true;
} catch (IOException e) {
return false; // xdg-open not installed
}
}
The started program's output is discarded and the launcher does not wait for it. LaunchFeedback.announce() comes first, so the busy cursor appears while the other program starts.
IOException means nothing could open it
browse and open throw only when neither xdg-open nor Desktop could be started. What xdg-open does afterwards (no program for the type, a broken file) it shows the user itself. A program shows the exception as a line in its status bar, as Web does (chapter 11).
Files that could run: isRisky¶
Package desktop · introduced in 11. Web, used for every attachment in 22. Mail · source: DesktopLauncher
What it is for. xdg-open on a shell script or a .desktop file can run it. For a link the user clicked on purpose, that is the user's decision. For an attachment from a stranger, it is how computers get infected. isRisky says whether a file name is of that kind:
/** Attachments with these extensions are never opened directly, only saved. */
private static final Set<String> RISKY = Set.of("desktop", "sh", "bash", "run", "bin", "jar", "py", "pl",
"appimage", "deb", "exe", "bat", "cmd", "com", "msi", "ps1", "vbs", "js", "jnlp", "lnk", "scr");
public static boolean isRisky(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot < 0 || RISKY.contains(fileName.substring(dot + 1).toLowerCase(Locale.ROOT));
}
When to choose it. For every file that came from someone else: mail attachments, downloads. A risky file is offered for saving, never opened. Files the user sees on their own disk, as in Files and Web, need no check.
How it works. The last extension decides, ignoring case, so Invoice.PDF.desktop is a desktop file. A file without an extension counts as risky: on Linux a program does not need one.
Test: risky and harmless names
@Test
void riskyAttachmentsAreRecognised() {
assertTrue(DesktopLauncher.isRisky("setup.sh"));
assertTrue(DesktopLauncher.isRisky("Invoice.PDF.desktop"));
assertTrue(DesktopLauncher.isRisky("tool.JAR"));
assertTrue(DesktopLauncher.isRisky("README"), "no extension: unknown, treat as risky");
assertFalse(DesktopLauncher.isRisky("report.pdf"));
assertFalse(DesktopLauncher.isRisky("photo.jpeg"));
}
The busy cursor: LaunchFeedback¶
Package desktop · introduced in 20. Files · source: LaunchFeedback
What it is for. Telling the amadeOS window manager that a program is starting, so that it shows the busy cursor until the new window appears, or for 20 seconds at most. Without a sign, the user double-clicks again and gets two editors. The Front Panel does this for the programs it starts; a program that starts others calls LaunchFeedback.announce().
When to choose it. Before starting a program that opens a window. DesktopLauncher already calls it; Files calls it before it starts the Text Editor itself.
How it works. It sends one X11 message, a ClientMessage of type _AMADEOS_LAUNCH, to the root window, where the window manager listens. Java has no API for X11 messages, so LaunchFeedback calls five functions of libX11 through the foreign function and memory API: no JNI, no C compiler, no native library of its own. The event's C structure is filled field by field in a MemorySegment.
/** Asks the window manager to show the busy cursor until the next new window. Never throws. */
public static void announce() {
if (unavailable) return;
if (!LaunchFeedback.class.getModule().isNativeAccessEnabled()) {
unavailable = true;
LOG.debug("No launch feedback: native access is not enabled for this application");
return;
}
try {
send(xlib());
} catch (Throwable e) {
unavailable = true;
LOG.debug("No launch feedback: X11 not available", e);
}
}
Never throws is the contract. Without native access, without an X display or without libX11, it notes that it is unavailable and does nothing from then on.
Pitfall: native access
Java asks a program for permission before it calls C code, and prints a warning otherwise. A program allows it with Enable-Native-Access: ALL-UNNAMED in its jar's manifest (chapter 20). LaunchFeedback checks that permission before anything else, so a program without it loses the cursor but gets no warning.
Passwords: PasswordStore¶
Package desktop · introduced in 22. Mail · source: PasswordStore, SecretToolPasswordStore, SessionPasswordStore
What it is for. Keeping passwords and tokens outside settings files, in the desktop keyring: the freedesktop Secret Service that gnome-keyring, KDE Wallet and KeePassXC offer, encrypted and unlocked with the login password.
package eu.amadeos.commons.desktop;
import java.util.Optional;
/**
* Keeps secrets (passwords, tokens) outside configuration files, keyed by an application-defined key
* such as an account id.
*/
public interface PasswordStore {
Optional<String> lookup(String key);
void store(String key, String label, String password);
void delete(String key);
/** False if passwords only live for the running session. */
boolean isPersistent();
}
The key is the program's own name for the secret, such as an account id. The label is what a keyring program shows next to it.
When to choose it. For every secret a program must remember: Mail's passwords and its sign-in tokens. CommonsModule binds a SecretToolPasswordStore under the program's id, so its entries carry service=amade-mail and account=<key> and can be listed with secret-tool search service amade-mail. Tests use a SessionPasswordStore, which keeps everything in memory.
How it works. Java has no API for the Secret Service, and a D-Bus library would be a large dependency for three calls. The small command secret-tool from the Debian package libsecret-tools makes exactly these calls. Every password goes into a session cache first, so it works for the rest of the session even when the keyring refuses it; a lookup asks the cache before the keyring:
@Override
public Optional<String> lookup(String key) {
Optional<String> cached = cache.lookup(key);
if (cached.isPresent() || executable == null) return cached;
Result result = run(null, executable, "lookup", "service", service, "account", key);
if (result.exitCode() != 0 || result.output().isEmpty()) return Optional.empty();
cache.store(key, "", result.output());
return Optional.of(result.output());
}
@Override
public void store(String key, String label, String password) {
cache.store(key, label, password);
if (executable == null) return;
Result result = run(password, executable, "store", "--label=" + label,
"service", service, "account", key);
if (result.exitCode() != 0) {
LOG.warn("secret-tool could not store the password for {} (exit {}); keeping it for this session only",
key, result.exitCode());
}
}
The command gets the password on its standard input and has 30 seconds to finish. A locked keyring shows an unlock dialog, and a user who walks away must not leave the program waiting for good:
private static Result run(String input, String... command) {
try {
Process process = new ProcessBuilder(List.of(command)).redirectErrorStream(false).start();
try (OutputStream stdin = process.getOutputStream()) {
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
}
// The output is a single password, far below the pipe buffer, so waiting first cannot dead-lock
// and a hanging keyring (e.g. waiting for an unlock prompt) cannot block the caller forever.
if (!process.waitFor(30, TimeUnit.SECONDS)) {
process.destroyForcibly();
return new Result(-1, "");
}
String output;
try (InputStream stdout = process.getInputStream(); InputStream stderr = process.getErrorStream()) {
output = new String(stdout.readAllBytes(), StandardCharsets.UTF_8);
stderr.readAllBytes();
}
return new Result(process.exitValue(), output);
} catch (IOException e) {
LOG.warn("Cannot run secret-tool", e);
return new Result(-1, "");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new Result(-1, "");
}
}
SecretToolPasswordStore is older than CommandRunner and starts its process itself; its output is a single password, far below the size of a pipe, so waiting before reading cannot dead-lock.
Pitfall: secrets on the command line
Never pass a secret as a command-line argument. The arguments of every process can be read by every user of the computer, in /proc/<pid>/cmdline and in ps. The label and the attributes are not secret and may be arguments; the password goes through standard input.
No keyring, no pretending
Without secret-tool, isPersistent() is false and passwords live for the session only. A program says so honestly: Mail turns its Remember password box off and explains why (chapter 22). There is no fallback to a file "just this once"; that is how passwords end up in plain text.
Test: without secret-tool, passwords live for the session
@Test
void withoutSecretToolPasswordsLiveForTheSession() {
SecretToolPasswordStore store = new SecretToolPasswordStore("amade-test", null);
assertFalse(store.isPersistent());
assertTrue(store.lookup("a1").isEmpty());
store.store("a1", "label", "secret");
assertEquals("secret", store.lookup("a1").orElseThrow());
store.delete("a1");
assertTrue(store.lookup("a1").isEmpty());
assertThrows(IllegalArgumentException.class, () -> new SecretToolPasswordStore(" "));
}
A picture on the clipboard: ImageSelection¶
Package desktop · introduced in 34. Screenshot, used by Fractals and Imagescan · source: ImageSelection
What it is for. Copying a picture, the counterpart of Swing's StringSelection for text. Java offers the picture to the pasting program as PNG and the other image types it asks for.
| Method | What it does |
|---|---|
ImageSelection.copy(image) |
Puts the picture on the system clipboard |
ImageSelection.put(clipboard, image, lost) |
Puts it on any clipboard; lost runs when another program takes the clipboard over |
When to choose it. For every Copy of a picture. Use put with a lost callback when the program needs to know that its picture is no longer on the clipboard, as Screenshot does to end in the background (chapter 34).
How it works. The record is both the Transferable, which hands out the picture for DataFlavor.imageFlavor, and the ClipboardOwner, which hears about the loss:
/**
* A picture on the clipboard. Java offers it to other programs as PNG and other image types. When another
* program puts something on the clipboard, {@code lost} runs; on X11 the picture is gone from then on.
*/
public record ImageSelection(Image image, Runnable lost) implements Transferable, ClipboardOwner {
/** Puts the picture on the system clipboard. */
public static void copy(Image image) {
put(Toolkit.getDefaultToolkit().getSystemClipboard(), image, () -> { });
}
/** Puts the picture on {@code clipboard}; {@code lost} runs when another program takes the clipboard over. */
public static void put(Clipboard clipboard, Image image, Runnable lost) {
ImageSelection selection = new ImageSelection(image, lost);
clipboard.setContents(selection, selection);
}
@Override
public DataFlavor[] getTransferDataFlavors() {
return new DataFlavor[]{DataFlavor.imageFlavor};
}
@Override
public boolean isDataFlavorSupported(DataFlavor flavor) {
return DataFlavor.imageFlavor.equals(flavor);
}
@Override
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
if (!isDataFlavorSupported(flavor)) throw new UnsupportedFlavorException(flavor);
return image;
}
@Override
public void lostOwnership(Clipboard clipboard, Transferable contents) {
lost.run();
}
}
Pitfall: on X11 the program holds the picture
The X11 clipboard is only the name of the program that owns the data. When that program ends, there is nothing to paste. A program that may end soon after Copy has to stay in the background until lost runs, or leave the data to a clipboard manager.
Test: the owner hears that it lost the clipboard
@Test
void anImageOnTheClipboardKnowsWhenItIsReplaced() throws Exception {
Clipboard clipboard = new Clipboard("test");
BufferedImage picture = new BufferedImage(4, 3, BufferedImage.TYPE_INT_RGB);
AtomicInteger lost = new AtomicInteger();
ImageSelection.put(clipboard, picture, lost::incrementAndGet);
assertSame(picture, clipboard.getData(DataFlavor.imageFlavor));
assertFalse(clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor));
clipboard.setContents(new StringSelection("text"), null);
EventQueue.invokeAndWait(() -> { }); // the clipboard tells the old owner on the event thread
assertEquals(1, lost.get());
}
The address books: ContactDirectory¶
Package contacts · introduced in 25. Contacts, used by Mail and Calendar · source: ContactDirectory
What it is for. Reading the contacts that amade-contacts keeps in ~/.local/share/amadeos/contacts, one folder per address book and one vCard file per contact, from any program. It reads; it never writes.
| Method | What it does |
|---|---|
ContactDirectory.of(xdg) |
The directory of the running user (CommonsModule binds one) |
contacts() |
Every contact as a ContactCard: name, nickname, organization, e-mail addresses (preferred first), birthday, groups |
complete(typed, limit) |
Recipients for an address field: people whose name, nickname or address has a word starting with typed, then groups |
birthdays(from, to) |
The birthdays in a range of days, as BirthdayOn with the date and the age |
refresh() |
Looks at the folder again on the next call |
Recipient.mailbox(name, address) |
Anna Berger <anna@example.org>, quoted where RFC 5322 needs it |
When to choose it. Whenever a program needs a person's address or birthday. Ask for it in a constructor; do not read the vCard files yourself, and do not change them: amade-contacts is the program that writes contacts, and it keeps what it does not understand.
How it works. A small reader of its own takes the few properties it needs from vCard 2.1, 3.0 and 4.0 and skips the rest, so the programs do not carry a vCard library. The folder is looked at again at most every two seconds, and only changed files are read again, so calling it on every key press is cheap:
private void rescan() {
Map<Path, CachedFile> seen = new LinkedHashMap<>();
for (Path file : vcardFiles()) {
try {
FileTime modified = Files.getLastModifiedTime(file);
CachedFile cached = files.get(file);
if (cached == null || !cached.modified().equals(modified)) {
cached = new CachedFile(modified, read(file));
}
seen.put(file, cached);
} catch (IOException e) {
LOG.debug("Cannot read {}: {}", file, e.toString());
}
}
files.clear();
files.putAll(seen);
List<ContactCard> all = new ArrayList<>();
for (CachedFile f : seen.values()) all.addAll(f.cards());
all.sort(Comparator.comparing((ContactCard c) -> fold(c.label())).thenComparing(ContactCard::uid));
contacts = List.copyOf(all);
}
Pitfall: HOME does not move the address books
XdgDirectories.system() uses the Java property user.home, not the HOME variable. A test or a screenshot with its own demo contacts must pass -Duser.home=… or build its own XdgDirectories.
Test: names, words, addresses and groups
@Test
void completesNamesWordsAddressesAndGroups() throws IOException {
write("personal", "a.vcf", card("a", "FN:Anna Berger", "EMAIL;PREF=1:anna@example.org",
"EMAIL:a.berger@firma.example", "CATEGORIES:Chor"));
write("personal", "b.vcf", card("b", "FN:Jörg Müller", "NICKNAME:Mulle", "EMAIL:joerg@example.de",
"CATEGORIES:Chor"));
write("personal", "c.vcf", card("c", "FN:Hannah Arendt", "EMAIL:hannah@example.org"));
write("personal", "d.vcf", card("d", "FN:Ohne Adresse"));
ContactDirectory directory = new ContactDirectory(root, new TestClock());
assertEquals(List.of("Anna Berger <anna@example.org>", "Anna Berger <a.berger@firma.example>"),
texts(directory.complete("an", 8)), "the preferred address first");
assertEquals(List.of("Hannah Arendt <hannah@example.org>"), texts(directory.complete("ar", 8)));
assertEquals(List.of("Jörg Müller <joerg@example.de>"), texts(directory.complete("mull", 8)));
assertEquals(List.of("Anna Berger <a.berger@firma.example>"), texts(directory.complete("firma", 8)));
assertEquals(List.of(), directory.complete("ohne", 8), "a contact without an address is no recipient");
Recipient choir = directory.complete("cho", 8).getFirst();
assertTrue(choir.group());
assertEquals("Chor", choir.name());
assertEquals("Anna Berger <anna@example.org>, Jörg Müller <joerg@example.de>", choir.text());
assertEquals(1, directory.complete("a", 1).size());
assertEquals(List.of(), directory.complete(" ", 8));
}
Completing addresses: AddressCompletion¶
Package contacts · introduced in 25. Contacts, used by Mail and Calendar · source: AddressCompletion
What it is for. A list of suggestions under a text field that holds e-mail addresses separated by commas, as the user types.
| Method | What it does |
|---|---|
AddressCompletion.install(field, directory, locale) |
Completes from the address books; starts reading them at once in the background |
AddressCompletion.install(field, suggestions, locale) |
Completes with any function from the typed text to Recipients, for tests or other sources |
When to choose it. For every field where the user types e-mail addresses: To, Cc and Bcc in the Mailer, the Mail To address of a Calendar reminder.
How it works. It listens to the field's document, takes the address under the caret between the commas, and shows the suggestions in a popup that cannot take the focus, so the user goes on typing. Up and Down choose, Enter or Tab takes one, Escape closes the list. A taken suggestion replaces what was typed and adds , for the next address; a group puts in all of its members. Addresses that are in the field already are not offered again.
/** Where the address under the caret starts and ends: between the commas (or semicolons) around it. */
record Token(int start, int end, String text) {
}
static Token token(String text, int caret) {
int start = 0;
boolean quoted = false;
for (int i = 0; i < caret; i++) {
char c = text.charAt(i);
if (c == '"') quoted = !quoted;
else if ((c == ',' || c == ';') && !quoted) start = i + 1;
}
int end = caret;
while (end < text.length() && text.charAt(end) != ',' && text.charAt(end) != ';') end++;
return new Token(start, end, text.substring(start, caret).strip());
}
Tip: Tab while the list is open
The field turns off Swing's focus traversal keys so that Tab reaches the completion while the list is open, and moves the focus itself when the list is closed. Shift+Tab goes back as usual.