Draw the interface with TeamSpeak icon packs

Icon packs are read in TeamSpeak's own format — a zip (or unpacked folder)
of SVG/PNG art plus a settings.ini mapping icon keys to files — so the packs
of an installed client and the ones from its add-on site work unchanged.
Legacy packs without that mapping (default.zip) are resolved by their file
naming convention instead, picking the resolution closest to the drawn size.

The pack draws the tree, toolbar, menus, context menus, file browser and the
default group icons; a pack's FALLBACK option decides whether what it lacks
comes from default.zip, and anything still missing falls back to the icons
the client draws itself. Options → Design picks the pack and shows a viewer
of everything in it.

Vector art is rasterised with JSVG, and the pack search roots are shared
with the sound packs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:46:39 +00:00
parent e1f62ab50d
commit 676e95023e
21 changed files with 1268 additions and 195 deletions

View File

@@ -0,0 +1,60 @@
package com.ts3client.config;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Locates the folders add-ons (sound packs, icon packs) are loaded from: the
* client's own folder, a folder the user configured, and the matching folder of
* an installed TeamSpeak 3 client, whose add-ons are usable as they are.
*/
public final class InstallDirs {
/** Overrides the TeamSpeak install location the add-ons are borrowed from. */
private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir";
private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR";
private InstallDirs() {
}
/**
* @param subfolder the add-on folder inside a TeamSpeak installation ({@code sound}, {@code gfx})
* @param userDirectory the client's own add-on folder (may not exist yet)
* @param extraDirectory an additional folder chosen by the user, or {@code null}
* @return the folders that exist, most specific first
*/
public static List<File> searchRoots(String subfolder, File userDirectory, String extraDirectory) {
List<File> roots = new ArrayList<>();
if (userDirectory != null) roots.add(userDirectory);
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), subfolder));
String home = System.getProperty("user.home", ".");
for (String candidate : new String[]{
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
roots.add(new File(new File(home, candidate), subfolder));
}
for (String candidate : new String[]{
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
"/usr/share/teamspeak3-client"}) {
roots.add(new File(candidate, subfolder));
}
String programFiles = System.getenv("ProgramFiles");
if (programFiles != null) {
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + subfolder));
}
// Running from a checkout that has the official client unpacked next to it.
roots.add(new File("TeamSpeak3-Client-linux_amd64/" + subfolder));
roots.add(new File("../TeamSpeak3-Client-linux_amd64/" + subfolder));
List<File> existing = new ArrayList<>();
for (File root : roots) {
if (root.isDirectory()) existing.add(root);
}
return existing;
}
}

View File

@@ -99,6 +99,12 @@ public final class Settings {
public double soundVolume = 1.0;
/** Extra folder to look for sound packs in, on top of the well-known locations. */
public String soundPackDir = "";
// ---- icons ----
/** File name of the active icon pack; empty uses the built-in icons. */
public String iconPack = "default_colored_2014.zip";
/** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = "";
/** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings();
@@ -169,6 +175,8 @@ public final class Settings {
soundPack = props.getProperty("soundPack", soundPack);
soundVolume = parseD(props.getProperty("soundVolume"), soundVolume);
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
notifications.load(props);
}
@@ -201,6 +209,8 @@ public final class Settings {
props.setProperty("soundPack", soundPack);
props.setProperty("soundVolume", Double.toString(soundVolume));
props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir);
notifications.store(props);
}

View File

@@ -0,0 +1,314 @@
package com.ts3client.gfx;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* A TeamSpeak icon pack: a zip (or unpacked directory) of images plus an optional
* {@code settings.ini} naming the file behind every icon of the user interface.
* The format is TeamSpeak's, so the packs shipped with the official client and the
* ones from its add-on site can be used as they are:
*
* <pre>
* [options]
* FALLBACK=true
*
* [gfxfiles]
* CHANNEL_GREEN = channel_green.svg
* CONNECT = connect.svg
* </pre>
*
* <p>Packs without a {@code settings.ini} are the legacy ones (TeamSpeak's own
* {@code default.zip}), whose files are named after the icon key directly, each
* prefixed with the size it was drawn for: {@code 16x16_connect.png}. Those are
* resolved by convention, picking the size closest to the one that is asked for.
*
* <p>Icon data is returned as bytes; rasterising it (the packs from 2014 on are
* vector art) is the frontend's job.
*/
public final class IconPack {
private static final String INI = "settings.ini";
/** Legacy file names: {@code <width>x<height>_<key>.<ext>}. */
private static final Pattern SIZED = Pattern.compile("(\\d+)x(\\d+)_(.+)");
/** One image inside a pack, with the size it was authored at (0 when unknown). */
private record Entry(String path, int size) {
}
private final String id;
private final File source;
private final String name;
private final boolean zipped;
private final boolean fallback;
/** Icon key to the images that can serve it, smallest first. */
private final Map<String, List<Entry>> entries;
private IconPack(String id, File source, String name, boolean zipped, boolean fallback,
Map<String, List<Entry>> entries) {
this.id = id;
this.source = source;
this.name = name;
this.zipped = zipped;
this.fallback = fallback;
this.entries = entries;
}
/** Stable identifier used in the settings: the file (or directory) name. */
public String id() {
return id;
}
public String name() {
return name;
}
public File source() {
return source;
}
/** Whether missing icons should be looked up in the default pack, as the pack asks. */
public boolean fallsBack() {
return fallback;
}
/** All icon keys this pack can draw, sorted. */
public List<String> keys() {
return new ArrayList<>(new TreeSet<>(entries.keySet()));
}
public boolean has(String key) {
return entries.containsKey(normalizeKey(key));
}
/**
* Reads the image for an icon.
*
* @param key a TeamSpeak icon key, e.g. {@code CHANNEL_GREEN}
* @param preferredSize the size it will be drawn at, so packs holding several
* resolutions can pick the closest one
* @return the image bytes (PNG or SVG), or {@code null} if the pack has no such icon
*/
public byte[] icon(String key, int preferredSize) {
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
if (entry == null) return null;
try {
return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath());
} catch (IOException e) {
return null;
}
}
/** @return whether the icon's image is vector art the frontend has to rasterise */
public boolean isVector(String key, int preferredSize) {
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
return entry != null && entry.path().toLowerCase(Locale.ROOT).endsWith(".svg");
}
@Override
public String toString() {
return name;
}
/** The smallest image that still covers the wanted size, else the largest one. */
private static Entry select(List<Entry> candidates, int preferredSize) {
if (candidates == null || candidates.isEmpty()) return null;
for (Entry e : candidates) {
if (e.size() == 0 || e.size() >= preferredSize) return e;
}
return candidates.get(candidates.size() - 1);
}
private byte[] readFromZip(String path) throws IOException {
try (ZipFile zip = new ZipFile(source)) {
ZipEntry entry = zip.getEntry(path);
if (entry == null) return null;
try (InputStream in = zip.getInputStream(entry)) {
return in.readAllBytes();
}
}
}
private static String normalizeKey(String key) {
return key == null ? "" : key.trim().toUpperCase(Locale.ROOT);
}
// ---- loading ----
/**
* Reads a pack from a {@code .zip} file or from a directory holding its contents.
*
* @return the pack, or {@code null} when it holds no icons
*/
public static IconPack load(File source) {
if (source == null) return null;
boolean zipped = source.isFile();
if (!zipped && !source.isDirectory()) return null;
Map<String, Long> files = zipped ? listZip(source) : listDirectory(source);
if (files == null || files.isEmpty()) return null;
String iniPath = files.keySet().stream()
.filter(p -> p.equalsIgnoreCase(INI))
.findFirst().orElse(null);
Map<String, String> mapping = new LinkedHashMap<>();
Map<String, String> info = new LinkedHashMap<>();
boolean fallback = true;
if (iniPath != null) {
byte[] ini = read(source, zipped, iniPath);
if (ini != null) fallback = parseIni(ini, mapping, info);
}
Map<String, List<Entry>> entries = iniPath != null
? fromMapping(mapping, files)
: byConvention(files);
if (entries.isEmpty()) return null;
String id = source.getName();
String name = info.getOrDefault("name", displayName(id));
return new IconPack(id, source, name, zipped, fallback, entries);
}
/** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */
private static String displayName(String fileName) {
String base = fileName.toLowerCase(Locale.ROOT).endsWith(".zip")
? fileName.substring(0, fileName.length() - 4) : fileName;
StringBuilder out = new StringBuilder();
for (String word : base.split("[_\\-\\s]+")) {
if (word.isEmpty()) continue;
if (out.length() > 0) out.append(' ');
out.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return out.length() == 0 ? fileName : out.toString();
}
/** @return the pack's FALLBACK option */
private static boolean parseIni(byte[] data, Map<String, String> mapping, Map<String, String> info) {
boolean fallback = true;
try (BufferedReader in = new BufferedReader(
new InputStreamReader(new java.io.ByteArrayInputStream(data), StandardCharsets.UTF_8))) {
String section = "";
String line;
while ((line = in.readLine()) != null) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
section = trimmed.substring(1, trimmed.length() - 1).trim().toLowerCase(Locale.ROOT);
continue;
}
int eq = trimmed.indexOf('=');
if (eq < 0) continue;
String key = trimmed.substring(0, eq).trim();
String value = trimmed.substring(eq + 1).trim();
switch (section) {
case "gfxfiles" -> {
if (!value.isEmpty()) mapping.put(normalizeKey(key), value);
}
case "info" -> info.put(key.toLowerCase(Locale.ROOT), value);
case "options" -> {
if (key.equalsIgnoreCase("FALLBACK")) fallback = !value.equalsIgnoreCase("false");
}
default -> {
}
}
}
} catch (IOException ignored) {
}
return fallback;
}
private static Map<String, List<Entry>> fromMapping(Map<String, String> mapping, Map<String, Long> files) {
Map<String, List<Entry>> entries = new LinkedHashMap<>();
for (Map.Entry<String, String> e : mapping.entrySet()) {
String path = resolve(e.getValue(), files);
if (path != null) entries.put(e.getKey(), List.of(new Entry(path, 0)));
}
return entries;
}
/**
* Legacy packs (and the extra files of newer ones) are keyed by their name:
* {@code 16x16_connect.png} serves {@code CONNECT}, as does {@code 32x32_connect.png}
* when no smaller one exists.
*/
private static Map<String, List<Entry>> byConvention(Map<String, Long> files) {
Map<String, List<Entry>> entries = new LinkedHashMap<>();
for (String path : files.keySet()) {
int dot = path.lastIndexOf('.');
if (dot < 0 || path.indexOf('/') >= 0) continue;
String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!ext.equals("png") && !ext.equals("svg")) continue;
String base = path.substring(0, dot);
int size = 0;
Matcher m = SIZED.matcher(base);
if (m.matches()) {
size = Integer.parseInt(m.group(1));
base = m.group(3);
}
entries.computeIfAbsent(normalizeKey(base), k -> new ArrayList<>()).add(new Entry(path, size));
}
for (List<Entry> candidates : entries.values()) {
candidates.sort((a, b) -> Integer.compare(a.size(), b.size()));
}
return entries;
}
/** Pack authors write file names as they see them, so a mismatched case is retried. */
private static String resolve(String fileName, Map<String, Long> files) {
if (files.containsKey(fileName)) return fileName;
for (String path : files.keySet()) {
if (path.equalsIgnoreCase(fileName)) return path;
}
return null;
}
private static byte[] read(File source, boolean zipped, String path) {
try {
if (!zipped) return Files.readAllBytes(new File(source, path).toPath());
try (ZipFile zip = new ZipFile(source)) {
ZipEntry entry = zip.getEntry(path);
if (entry == null) return null;
try (InputStream in = zip.getInputStream(entry)) {
return in.readAllBytes();
}
}
} catch (IOException e) {
return null;
}
}
private static Map<String, Long> listZip(File file) {
if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null;
Map<String, Long> files = new LinkedHashMap<>();
try (ZipFile zip = new ZipFile(file)) {
zip.stream().filter(e -> !e.isDirectory()).forEach(e -> files.put(e.getName(), e.getSize()));
} catch (IOException e) {
return null;
}
return files;
}
private static Map<String, Long> listDirectory(File dir) {
Map<String, Long> files = new LinkedHashMap<>();
File[] children = dir.listFiles(File::isFile);
if (children == null) return files;
for (File f : children) files.put(f.getName(), f.length());
return files;
}
}

View File

@@ -0,0 +1,64 @@
package com.ts3client.gfx;
import com.ts3client.config.InstallDirs;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Finds the icon packs installed on this machine: the client's own {@code gfx/}
* folder, an extra folder the user configured, and the {@code gfx/} folder of an
* installed TeamSpeak 3 client (whose packs are usable as they are, see
* {@link IconPack}).
*/
public final class IconPacks {
/** The pack every other one falls back to, as TeamSpeak does. */
public static final String DEFAULT_ID = "default.zip";
private IconPacks() {
}
/**
* Loads every pack found under {@code extraDirectory} and the well-known
* locations, the first search root winning on a name clash.
*
* @param userDirectory the client's own pack folder (may not exist yet)
* @param extraDirectory an additional folder chosen by the user, or {@code null}
* @return the packs, ordered by display name
*/
public static List<IconPack> findAll(File userDirectory, String extraDirectory) {
Map<String, IconPack> byId = new LinkedHashMap<>();
for (File root : InstallDirs.searchRoots("gfx", userDirectory, extraDirectory)) {
File[] children = root.listFiles();
if (children == null) continue;
for (File child : children) {
if (child.isFile() && !child.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) continue;
if (byId.containsKey(child.getName())) continue;
IconPack pack = IconPack.load(child);
if (pack != null) byId.put(pack.id(), pack);
}
}
List<IconPack> packs = new ArrayList<>(byId.values());
packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT)));
return packs;
}
/** @return the pack with this id, or {@code null} when it is not installed */
public static IconPack select(List<IconPack> packs, String id) {
for (IconPack p : packs) {
if (p.id().equals(id)) return p;
}
return null;
}
/** @return the pack unresolved icons are taken from, or {@code null} */
public static IconPack fallback(List<IconPack> packs) {
return select(packs, DEFAULT_ID);
}
}

View File

@@ -1,5 +1,7 @@
package com.ts3client.sound;
import com.ts3client.config.InstallDirs;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
@@ -15,10 +17,6 @@ import java.util.Map;
*/
public final class SoundPacks {
/** Overrides the TeamSpeak install location the packs are borrowed from. */
private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir";
private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR";
private SoundPacks() {
}
@@ -32,7 +30,7 @@ public final class SoundPacks {
*/
public static List<SoundPack> findAll(File userDirectory, String extraDirectory) {
Map<String, SoundPack> byId = new LinkedHashMap<>();
for (File root : searchRoots(userDirectory, extraDirectory)) {
for (File root : InstallDirs.searchRoots("sound", userDirectory, extraDirectory)) {
File[] entries = root.listFiles(File::isDirectory);
if (entries == null) continue;
for (File dir : entries) {
@@ -52,38 +50,4 @@ public final class SoundPacks {
}
return packs.isEmpty() ? null : packs.get(0);
}
private static List<File> searchRoots(File userDirectory, String extraDirectory) {
List<File> roots = new ArrayList<>();
if (userDirectory != null) roots.add(userDirectory);
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), "sound"));
String home = System.getProperty("user.home", ".");
for (String candidate : new String[]{
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
roots.add(new File(new File(home, candidate), "sound"));
}
for (String candidate : new String[]{
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
"/usr/share/teamspeak3-client"}) {
roots.add(new File(candidate, "sound"));
}
String programFiles = System.getenv("ProgramFiles");
if (programFiles != null) {
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + "sound"));
}
// Running from a checkout that has the official client unpacked next to it.
roots.add(new File("TeamSpeak3-Client-linux_amd64/sound"));
roots.add(new File("../TeamSpeak3-Client-linux_amd64/sound"));
List<File> existing = new ArrayList<>();
for (File root : roots) {
if (root.isDirectory()) existing.add(root);
}
return existing;
}
}

View File

@@ -0,0 +1,127 @@
package com.ts3client.gfx;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IconPackTest {
private static final String INI = """
[info]
name = Test Icons
[options]
FALLBACK = false
[gfxfiles]
# a comment
CONNECT = connect.svg
DISCONNECT = Disconnect.SVG
CHANNEL_GREEN= channel_green.png
MISSING = not_shipped.svg
EMPTY =
""";
private static File zip(Path dir, String name, Map<String, String> entries) throws Exception {
File file = dir.resolve(name).toFile();
try (OutputStream out = Files.newOutputStream(file.toPath());
ZipOutputStream zip = new ZipOutputStream(out)) {
for (Map.Entry<String, String> e : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(e.getKey()));
zip.write(e.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return file;
}
@Test
void readsTheMappingOfAModernPack(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "test_pack.zip", Map.of(
"settings.ini", INI,
"connect.svg", "<svg/>",
"disconnect.svg", "<svg>disconnect</svg>",
"channel_green.png", "png bytes",
"emoticons/smile.svg", "<svg/>")));
assertNotNull(pack);
assertEquals("test_pack.zip", pack.id());
assertEquals("Test Icons", pack.name());
assertFalse(pack.fallsBack());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertTrue(pack.isVector("CONNECT", 16));
assertFalse(pack.isVector("CHANNEL_GREEN", 16));
// Keys are case-insensitive, as are the file names pack authors write.
assertArrayEquals("<svg>disconnect</svg>".getBytes(StandardCharsets.UTF_8),
pack.icon("disconnect", 16));
assertNull(pack.icon("MISSING", 16), "mapped to a file the pack does not ship");
assertNull(pack.icon("EMPTY", 16));
assertNull(pack.icon("NOT_IN_THE_PACK", 16));
}
@Test
void fallsBackByDefault(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "p.zip", Map.of(
"settings.ini", "[gfxfiles]\nCONNECT = connect.svg\n",
"connect.svg", "<svg/>")));
assertNotNull(pack);
assertTrue(pack.fallsBack());
}
@Test
void resolvesLegacyPacksByFileName(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "default.zip", Map.of(
"16x16_connect.png", "small",
"32x32_connect.png", "large",
"24x24_upload_avatar.png", "avatar",
"group_100.png", "group",
"emoticons/smile.png", "ignored")));
assertNotNull(pack);
assertEquals("Default", pack.name());
// The smallest image that still covers the requested size wins.
assertArrayEquals("small".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 32));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 64));
assertArrayEquals("avatar".getBytes(StandardCharsets.UTF_8), pack.icon("UPLOAD_AVATAR", 16));
assertArrayEquals("group".getBytes(StandardCharsets.UTF_8), pack.icon("GROUP_100", 16));
assertFalse(pack.has("SMILE"), "emoticons are not icons of the user interface");
}
@Test
void readsUnpackedDirectories(@TempDir Path dir) throws Exception {
Path unpacked = Files.createDirectory(dir.resolve("my_icons"));
Files.writeString(unpacked.resolve("settings.ini"), "[gfxfiles]\nCONNECT = connect.svg\n");
Files.writeString(unpacked.resolve("connect.svg"), "<svg/>");
IconPack pack = IconPack.load(unpacked.toFile());
assertNotNull(pack);
assertEquals("My Icons", pack.name());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
}
@Test
void ignoresWhatIsNotAPack(@TempDir Path dir) throws Exception {
assertNull(IconPack.load(dir.resolve("nope.zip").toFile()));
assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile()));
assertNull(IconPack.load(null));
}
}