diff --git a/README.md b/README.md index e262364..d181f7a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ This is a Java program requiring Java 8 or higher. * Right-click to copy name, size, or GUID * History.ini saves the last directory used +Alternatively, it can extract everything from the command line using the `--extract-all` command +``` +java -jar UnityPackageViewer.x.x.x.jar path/to/file.unitypackage --extract-all +``` # Disclaimers diff --git a/pom.xml b/pom.xml index b72ec6b..9735dc9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 m35-projects unity-package-viewer - 0.0.1 + 0.0.2 jar UTF-8 diff --git a/src/main/java/unitypackage/model/UnityArchiveInputStream.java b/src/main/java/unitypackage/model/UnityArchiveInputStream.java new file mode 100644 index 0000000..98a71e0 --- /dev/null +++ b/src/main/java/unitypackage/model/UnityArchiveInputStream.java @@ -0,0 +1,72 @@ +/* + * Basic .unitypackage Viewer + * Copyright (C) 2024 Michael Sabin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package unitypackage.model; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +/** + * Like {@link TarArchiveInputStream} but for .unitypackage files, + * except it skips all directories. + */ +public class UnityArchiveInputStream extends FilterInputStream { + + private final TarArchiveInputStream tarInputStream; + private final Map tarPathsToUnityAsset = new TreeMap<>(); + + public UnityArchiveInputStream(UnityPackage unityPackage) throws IOException { + this(unityPackage.getTarInputStream(), unityPackage.getUnityAssetList()); + } + + public UnityArchiveInputStream(TarArchiveInputStream tarInputStream, List unityAssetsToRead) { + super(tarInputStream); + this.tarInputStream = tarInputStream; + + for (UnityAsset unityAsset : unityAssetsToRead) { + if (!unityAsset.isProbablyDirectory()) { + tarPathsToUnityAsset.put(unityAsset.getTarPathOf_asset_File(), unityAsset); + } + } + } + + /** + * Like {@link TarArchiveInputStream#getNextEntry()} but for Unity asset files, + * except it only returns actual files, skipping directories. + */ + public UnityAsset getNextEntry() throws IOException { + + TarArchiveEntry entry; + // Seek for the file of interest + while ((entry = tarInputStream.getNextEntry()) != null) { + String tarEntryName = entry.getName(); + // Check if this file in the tar is an asset payload + UnityAsset unityAsset = tarPathsToUnityAsset.get(tarEntryName); + if (unityAsset != null) { + return unityAsset; + } + } + + return null; + } +} diff --git a/src/main/java/unitypackage/model/UnityAssetBuilder.java b/src/main/java/unitypackage/model/UnityAssetBuilder.java index dba0531..eca7e3a 100644 --- a/src/main/java/unitypackage/model/UnityAssetBuilder.java +++ b/src/main/java/unitypackage/model/UnityAssetBuilder.java @@ -102,18 +102,16 @@ public BufferedImage getPreview() { return _preview; } - public UnityAssetBuilder(String guidBaseDirectory) { - this.guidBaseDirectory = guidBaseDirectory; + public UnityAssetBuilder(String directoryGuidName) { + this.guidBaseDirectory = directoryGuidName; } - public UnityAssetBuilder(TarArchiveEntry tarEntry, + public UnityAssetBuilder(String guidBaseDirectory, String fileName, TarArchiveEntry tarEntry, TarArchiveInputStream tarInputStream) throws IOException { - String rawFilePath = tarEntry.getName(); - File rawFile = new File(rawFilePath); - guidBaseDirectory = rawFile.getParent(); + this.guidBaseDirectory = guidBaseDirectory; - addFileFoundInDirectory(tarEntry, tarInputStream); + addFileFoundInDirectory(this.guidBaseDirectory, fileName, tarEntry, tarInputStream); } public UnityAsset makeUnityAsset() { @@ -123,34 +121,28 @@ public UnityAsset makeUnityAsset() { /** * Sanity check that only files that belong in this directory are being added. */ - public void assertGuidMatchesDirectoryName(String guid) { - if (!guid.equals(guidBaseDirectory)) { - throw new IllegalArgumentException("Argument guid " + guid + " != this guid" + + public void assertGuidMatchesDirectoryName(String directoryGuidName) { + if (!directoryGuidName.equals(guidBaseDirectory)) { + throw new IllegalArgumentException("Argument guid " + directoryGuidName + " != this guid" + " dir " + guidBaseDirectory); } } - public void addFileFoundInDirectory(TarArchiveEntry tarEntry, + public void addFileFoundInDirectory(String directoryGuidName, String fileName, TarArchiveEntry tarEntry, TarArchiveInputStream tarInputStream) throws IOException { - String rawFilePath = tarEntry.getName(); - File rawFile = new File(rawFilePath); - - String directoryGuidName = rawFile.getParent(); assertGuidMatchesDirectoryName(directoryGuidName); - String rawFileName = rawFile.getName(); - - switch (rawFileName) { + switch (fileName) { case "asset": asset_fileSize = tarEntry.getRealSize(); - rawPathTo_asset_file = rawFilePath; + rawPathTo_asset_file = tarEntry.getName(); asset_dateModified = tarEntry.getLastModifiedDate(); break; case "asset.meta": asset_meta_guid = findGuidIn_asset_meta_File(tarEntry, tarInputStream); if (!asset_meta_guid.equals(guidBaseDirectory)) { - // afaik the directory guid should match the guid in the asset.meta file + // Usually the directory guid matches the guid in the asset.meta file, but not always String s = "Corrupted .unitypackage? directory guid" + " " + guidBaseDirectory + " != " + "asset.meta guid " + asset_meta_guid; if (STRICT) { throw new RuntimeException(s); @@ -166,7 +158,7 @@ public void addFileFoundInDirectory(TarArchiveEntry tarEntry, _preview = ImageIO.read(tarInputStream); break; default: - throw new RuntimeException("File name not recognized " + rawFilePath); + throw new RuntimeException("File name not recognized " + tarEntry.getName()); } } diff --git a/src/main/java/unitypackage/model/UnityPackage.java b/src/main/java/unitypackage/model/UnityPackage.java new file mode 100644 index 0000000..62e2df5 --- /dev/null +++ b/src/main/java/unitypackage/model/UnityPackage.java @@ -0,0 +1,171 @@ +/* + * Basic .unitypackage Viewer + * Copyright (C) 2024 Michael Sabin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package unitypackage.model; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.zip.GZIPInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +/** + * Indexes a .unitypackage and provides methods to read assets out of it. + */ +public class UnityPackage { + + private static final String ROOT_ICON = ".icon.png"; + + private final File unitypackageFile; + private final List unityAssetList; + + public UnityPackage(File unitypackageFile) throws IOException { + this.unitypackageFile = unitypackageFile; + + // TODO are empty asset directories possible? + // that would break this program + + TreeMap rootGuidDirectories = new TreeMap<>(); + + try (TarArchiveInputStream tarInput = getTarInputStream()) { + + boolean hasDotRootDirectory = false; + + TarArchiveEntry tarEntry; + while ((tarEntry = tarInput.getNextEntry()) != null) { + + final String rawFilePathString = tarEntry.getName(); + final boolean isDirectory = tarEntry.isDirectory(); + + Path rawPath = Paths.get(rawFilePathString); + + if (rawPath.getNameCount() == 1) { + String shouldBeDirName = rawPath.getName(0).toString(); + if (isDirectory) { + if (".".equals(shouldBeDirName)) { + hasDotRootDirectory = true; + continue; + } + } else if (!ROOT_ICON.equals(shouldBeDirName)) { + // I don't know if the root ".icon.png" would be next to a root "." or under it + throw new RuntimeException("Found root path that is not a directory or " + ROOT_ICON + ": " + rawFilePathString); + } + + } + + if (hasDotRootDirectory) { + // Trim off the "." before continuing + rawPath = rawPath.subpath(1, rawPath.getNameCount()); + } + + Path rawPathParent = rawPath.getParent(); + + String guidDirectory; + String fileName; + + if (isDirectory) { + if (rawPathParent != null) { + throw new RuntimeException("Found nested directory " + rawFilePathString); + } + guidDirectory = rawPath.toString(); + fileName = null; + } else { + fileName = rawPath.getFileName().toString(); + guidDirectory = rawPathParent == null ? null : rawPathParent.toString(); + } + + if (guidDirectory == null) { + if (fileName.equals(ROOT_ICON)) { + // Image icon exists in the root + // TODO do something with this + // For now ignore it + continue; + } else { + throw new RuntimeException("Found nested directory " + rawFilePathString); + } + } + + UnityAssetBuilder builder = rootGuidDirectories.get(guidDirectory); + + if (builder == null) { + if (isDirectory) { + builder = new UnityAssetBuilder(guidDirectory); + } else { + // Do .tar archives always put a directory definition before any files under it? + // In any case, be flexible. + builder = new UnityAssetBuilder(guidDirectory, fileName, tarEntry, tarInput); + } + rootGuidDirectories.put(guidDirectory, builder); + } else { + if (isDirectory) + builder.assertGuidMatchesDirectoryName(guidDirectory); + else + builder.addFileFoundInDirectory(guidDirectory, fileName, tarEntry, tarInput); + } + } + } + + List assets = rootGuidDirectories + .values() + .stream() + .map(tad -> tad.makeUnityAsset()) + .collect(Collectors.toList()); + + unityAssetList = Collections.unmodifiableList(assets); + } + + public File getUnitypackageFile() { + return unitypackageFile; + } + + public List getUnityAssetList() { + return unityAssetList; + } + + final public TarArchiveInputStream getTarInputStream() throws IOException { + return new TarArchiveInputStream(new GZIPInputStream(new FileInputStream(unitypackageFile))); + } + + public UnityArchiveInputStream getUnityArchiveInputStream() throws IOException { + return new UnityArchiveInputStream(this); + } + + public InputStream getFileStream(UnityAsset assetToExtract) throws IOException { + + UnityArchiveInputStream unityInputStream = new UnityArchiveInputStream(getTarInputStream(), + Collections.singletonList(assetToExtract)); + + UnityAsset assetFound = unityInputStream.getNextEntry(); + + // Sanity check + if (assetToExtract != assetFound) { + throw new IllegalArgumentException("This should never happen"); + } + + return unityInputStream; + } + +} diff --git a/src/main/java/unitypackage/model/UnitypackageReader.java b/src/main/java/unitypackage/model/UnitypackageReader.java deleted file mode 100644 index 24d2523..0000000 --- a/src/main/java/unitypackage/model/UnitypackageReader.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Basic .unitypackage Viewer - * Copyright (C) 2024 Michael Sabin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package unitypackage.model; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.List; -import java.util.TreeMap; -import java.util.stream.Collectors; -import java.util.zip.GZIPInputStream; -import org.apache.commons.compress.archivers.tar.TarArchiveEntry; -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; - -/** - * .unitypackage files are actually pretty simple. - * - * It's a .tar file with a flat list of directories of the GUIDs in the package. - * Inside each directory will contain files with these names: - * - * "pathname" = Contains one line of text that is the full path of the asset where it will appear when imported into Unity. - * "asset.meta" = The corresponding .meta file. - * "asset" = Contains the actual asset payload. Won't exist for directories. - * "preview.png" = Optional preview of some types of assets. - */ -public class UnitypackageReader { - - /** - * Gets the stream of the file being requested from the given unitypackage. - * @param unitypackageFile Should be the original file passed to {@link #readAssetsList(File)}. - * If asset is not found will throw {@link IllegalArgumentException}. - */ - public static InputStream getFileStream(UnityAsset assetToExtract, File unitypackageFile) throws IOException { - - TarArchiveInputStream tarInput = new TarArchiveInputStream(new GZIPInputStream(new FileInputStream(unitypackageFile))); - - String tarPathOf_asset_file = assetToExtract.getTarPathOf_asset_File(); - - TarArchiveEntry entry; - // Seek for the file of interest - while ((entry = tarInput.getNextEntry()) != null) { - if (entry.getName().equals(tarPathOf_asset_file)) { - return tarInput; - } - } - - tarInput.close(); - - throw new IllegalArgumentException("Could not find asset " + assetToExtract.getFullPath()); - } - - public static List readAssetsList(File unitypackageFile) throws IOException { - - // TODO are empty asset directories possible? - // that would break this program - - TreeMap rootGuidDirectories = new TreeMap<>(); - - try (TarArchiveInputStream tarInput = new TarArchiveInputStream(new GZIPInputStream(new FileInputStream(unitypackageFile)))) { - TarArchiveEntry tarEntry; - - while ((tarEntry = tarInput.getNextEntry()) != null) { - - String rawFilePath = tarEntry.getName(); - File rawFile = new File(rawFilePath); - - final boolean isDirectory = tarEntry.isDirectory(); - - String guidBaseDirectory; - if (isDirectory) { - if (rawFile.getParent() != null) { - // afaik .unitypackage should only have 1 level of directories - throw new RuntimeException("Found nested directory " + rawFilePath); - } - guidBaseDirectory = rawFile.getPath(); - } else { - guidBaseDirectory = rawFile.getParent(); - } - - if (guidBaseDirectory == null) { - if (".icon.png".equals(rawFilePath)) { - // Image icon exists in the root - // TODO do something with this - // For now ignore it - } else { - throw new RuntimeException("Found nested directory " + rawFilePath); - } - - } else { - UnityAssetBuilder builder = rootGuidDirectories.get(guidBaseDirectory); - - if (builder == null) { - if (isDirectory) { - builder = new UnityAssetBuilder(guidBaseDirectory); - } else { - // Do .tar archives always put a directory definition before any files under it? - // In any case, be flexible. - builder = new UnityAssetBuilder(tarEntry, tarInput); - } - rootGuidDirectories.put(guidBaseDirectory, builder); - } else { - if (isDirectory) - builder.assertGuidMatchesDirectoryName(guidBaseDirectory); - else - builder.addFileFoundInDirectory(tarEntry, tarInput); - } - } - } - } - - List assets = rootGuidDirectories - .values() - .stream() - .map(tad -> tad.makeUnityAsset()) - .collect(Collectors.toList()); - - return assets; - } - - -} diff --git a/src/main/java/unitypackage/viewer/Main.java b/src/main/java/unitypackage/viewer/Main.java index a099aea..4cbb800 100644 --- a/src/main/java/unitypackage/viewer/Main.java +++ b/src/main/java/unitypackage/viewer/Main.java @@ -18,20 +18,38 @@ package unitypackage.viewer; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Properties; +import unitypackage.model.UnityArchiveInputStream; +import unitypackage.model.UnityAsset; +import unitypackage.model.UnityPackage; import unitypackage.viewer.gui.MainWindow; +import unitypackage.viewer.gui.UnitypackageFileName; public class Main { - public static String VERSION = "(development)"; + private static final String VERSION_PROPERTY_FILE = "app.properties"; + public static String DEVELOPMENT_VERSION = "(development)"; + public static String VERSION = DEVELOPMENT_VERSION; + private static final String EXTRACT_ALL_COMMAND = "--extract-all"; + + /** + * Looks for the file {@link #VERSION_PROPERTY_FILE} that should have been filtered + * into the build with the application's version. But if not found, will default + * to {@link #DEVELOPMENT_VERSION}. + */ private static void initVersion() { - String resource = "app.properties"; ClassLoader classLoader = ClassLoader.getSystemClassLoader(); - try (InputStream propertyStream = classLoader.getResourceAsStream(resource)) { + try (InputStream propertyStream = classLoader.getResourceAsStream(VERSION_PROPERTY_FILE)) { if (propertyStream != null) { Properties properties = new Properties(); properties.load(propertyStream); @@ -45,14 +63,61 @@ private static void initVersion() { } } - public static void main(String[] args) { + public static void main(String[] args) throws IOException { initVersion(); + ArrayList argsList = new ArrayList<>(Arrays.asList(args)); + + boolean hasExtractAllCommand = false; + String fileToOpen = null; + + if (!argsList.isEmpty()) { + hasExtractAllCommand = argsList.remove(EXTRACT_ALL_COMMAND); + if (!argsList.isEmpty()) { + fileToOpen = argsList.get(0); + } + } + + if (hasExtractAllCommand) { + if (fileToOpen == null) { + System.out.println(EXTRACT_ALL_COMMAND + " expects a file to extract"); + System.exit(1); + } + extractAll(fileToOpen); + } else { + runGui(fileToOpen); + } + } + + private static void extractAll(String fileToOpen) throws IOException { + File file = new File(fileToOpen); + if (!UnitypackageFileName.isUnitypackage(file)) { + System.out.println("File \""+fileToOpen+"\" doesn't have a normal unitypackage file name."); + } + + UnityPackage unityPackage = new UnityPackage(file); + try (UnityArchiveInputStream unityIS = unityPackage.getUnityArchiveInputStream()) { + UnityAsset nextAsset; + while ((nextAsset = unityIS.getNextEntry()) != null) { + Path assetPath = nextAsset.getFullPathAsPath(); + Path parentDirectory = assetPath.getParent(); + if (parentDirectory != null) { + Files.createDirectories(parentDirectory); + } + + Files.copy(unityIS, assetPath, StandardCopyOption.REPLACE_EXISTING); + System.out.println("Extracted " + assetPath); + } + } + } + + private static void runGui(String fileToOpen) { + java.awt.EventQueue.invokeLater(new Runnable() { @Override public void run() { - MainWindow frame = new MainWindow(args.length > 0 ? args[0] : null ); + MainWindow frame = new MainWindow(fileToOpen); frame.setVisible(true); } }); diff --git a/src/main/java/unitypackage/viewer/gui/MainWindow.java b/src/main/java/unitypackage/viewer/gui/MainWindow.java index e66a773..b6e1b0d 100644 --- a/src/main/java/unitypackage/viewer/gui/MainWindow.java +++ b/src/main/java/unitypackage/viewer/gui/MainWindow.java @@ -471,7 +471,7 @@ private void guiExportButtonActionPerformed(java.awt.event.ActionEvent evt) {//G UnityAsset asset = assetNode.getAsset(); String assetFileName = asset.getFileName(); - Path outputFile = guiModel.getCurrentUnitypackageFile().toPath().getParent().resolve(assetFileName); + Path outputFile = guiModel.getCurrentUnitypackage().toPath().getParent().resolve(assetFileName); boolean exists = Files.exists(outputFile); if (exists) { int dialogResult = JOptionPane.showConfirmDialog(null, diff --git a/src/main/java/unitypackage/viewer/gui/model/UnitypackageGuiModel.java b/src/main/java/unitypackage/viewer/gui/model/UnitypackageGuiModel.java index db62c39..82436d9 100644 --- a/src/main/java/unitypackage/viewer/gui/model/UnitypackageGuiModel.java +++ b/src/main/java/unitypackage/viewer/gui/model/UnitypackageGuiModel.java @@ -35,18 +35,18 @@ import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreeNode; import unitypackage.model.UnityAsset; -import unitypackage.model.UnitypackageReader; +import unitypackage.model.UnityPackage; public class UnitypackageGuiModel { - private File currentUnitypackageFile; + private UnityPackage currentUnitypackage; - public File getCurrentUnitypackageFile() { - return currentUnitypackageFile; + public File getCurrentUnitypackage() { + return currentUnitypackage.getUnitypackageFile(); } public void extractFile(UnityAsset asset, Path outputFile) throws IOException { - try (InputStream is = UnitypackageReader.getFileStream(asset, currentUnitypackageFile)) { + try (InputStream is = currentUnitypackage.getFileStream(asset)) { Files.copy(is, outputFile, StandardCopyOption.REPLACE_EXISTING); } } @@ -88,10 +88,10 @@ public int compare(UnityAsset o1, UnityAsset o2) { * @param unitypackagePath Path to the ".unitypackage" file. */ public DefaultTreeModel buildTreeModel(File unitypackagePath) throws IOException { - currentUnitypackageFile = unitypackagePath; - List unityAssets = UnitypackageReader.readAssetsList(unitypackagePath); + currentUnitypackage = new UnityPackage(unitypackagePath); + List unityAssets = currentUnitypackage.getUnityAssetList(); UnityTreeNode.Directory root = new UnityTreeNode.Directory(Paths.get("(root)")); @@ -142,6 +142,7 @@ public DefaultTreeModel buildTreeModel(File unitypackagePath) throws IOException private static UnityTreeNode.Directory findOrCreateDirectoryNode(UnityTreeNode parent, Path relativePathFromParent) { + @SuppressWarnings("unchecked") Enumeration kids = parent.children(); while (kids.hasMoreElements()) { Object nextKid = kids.nextElement();