Adding analyze command to 'asu'; adding support to API for date

information.
This commit is contained in:
Rob Greene 2018-05-28 13:35:49 -05:00
parent fbab3b426e
commit 03095785d5
10 changed files with 516 additions and 13 deletions

View File

@ -10,6 +10,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@ -31,13 +32,34 @@ import java.util.function.Consumer;
*/
public class AppleSingle {
public static final int MAGIC_NUMBER = 0x0051600;
public static final int VERSION_NUMBER = 0x00020000;
public static final int VERSION_NUMBER1 = 0x00010000;
public static final int VERSION_NUMBER2 = 0x00020000;
public static final Map<Integer,String> ENTRY_TYPE_NAMES = new HashMap<Integer,String>() {
private static final long serialVersionUID = 7142066556402030814L;
{
put(1, "Data Fork");
put(2, "Resource Fork");
put(3, "Real Name");
put(4, "Comment");
put(5, "Icon, B&W");
put(6, "Icon, Color");
put(7, "File Info");
put(8, "File Dates Info");
put(9, "Finder Info");
put(10, "Macintosh File Info");
put(11, "ProDOS File Info");
put(12, "MS-DOS File Info");
put(13, "Short Name");
put(14, "AFP File Info");
put(15, "Directory ID");
}};
private Map<Integer,Consumer<byte[]>> entryConsumers = new HashMap<>();
{
entryConsumers.put(1, this::setDataFork);
entryConsumers.put(2, this::setResourceFork);
entryConsumers.put(3, this::setRealName);
entryConsumers.put(8, this::setFileDatesInfo);
entryConsumers.put(11, this::setProdosFileInfo);
}
@ -45,6 +67,7 @@ public class AppleSingle {
private byte[] resourceFork;
private String realName;
private ProdosFileInfo prodosFileInfo = ProdosFileInfo.standardBIN();
private FileDatesInfo fileDatesInfo = new FileDatesInfo();
private AppleSingle() {
// Allow Builder construction
@ -54,7 +77,7 @@ public class AppleSingle {
.order(ByteOrder.BIG_ENDIAN)
.asReadOnlyBuffer();
required(buffer, MAGIC_NUMBER, "Not an AppleSingle file - magic number does not match.");
required(buffer, VERSION_NUMBER, "Only AppleSingle version 2 supported.");
required(buffer, VERSION_NUMBER2, "Only AppleSingle version 2 supported.");
buffer.position(buffer.position() + 16); // Skip filler
int entries = buffer.getShort();
for (int i = 0; i < entries; i++) {
@ -67,7 +90,8 @@ public class AppleSingle {
buffer.get(entryData);
// Defer to the proper set method or crash if we don't support that type of entry
Optional.ofNullable(entryConsumers.get(entryId))
.orElseThrow(() -> new IOException(String.format("Unknown entry type of %04X", entryId)))
.orElseThrow(() -> new IOException(String.format("Unsupported entry type of %04X (%s)", entryId,
ENTRY_TYPE_NAMES.getOrDefault(entryId, "Unknown"))))
.accept(entryData);
buffer.reset();
}
@ -99,6 +123,16 @@ public class AppleSingle {
int auxType = infoData.getInt();
this.prodosFileInfo = new ProdosFileInfo(access, fileType, auxType);
}
private void setFileDatesInfo(byte[] entryData) {
ByteBuffer infoData = ByteBuffer.wrap(entryData)
.order(ByteOrder.BIG_ENDIAN)
.asReadOnlyBuffer();
int creation = infoData.getInt();
int modification = infoData.getInt();
int backup = infoData.getInt();
int access = infoData.getInt();
this.fileDatesInfo = new FileDatesInfo(creation, modification, backup, access);
}
public byte[] getDataFork() {
return dataFork;
@ -112,25 +146,31 @@ public class AppleSingle {
public ProdosFileInfo getProdosFileInfo() {
return prodosFileInfo;
}
public FileDatesInfo getFileDatesInfo() {
return fileDatesInfo;
}
public void save(OutputStream outputStream) throws IOException {
final boolean hasResourceFork = resourceFork == null ? false : true;
final boolean hasRealName = realName == null ? false : true;
final int entries = 2 + (hasRealName ? 1 : 0) + (hasResourceFork ? 1 : 0);
final boolean hasResourceFork = Objects.nonNull(resourceFork);
final boolean hasRealName = Objects.nonNull(realName);
final int entries = 3 + (hasRealName ? 1 : 0) + (hasResourceFork ? 1 : 0);
int realNameOffset = 26 + (12 * entries);
int prodosFileInfoOffset = realNameOffset + (hasRealName ? realName.length() : 0);
int resourceForkOffset = prodosFileInfoOffset + 8;
int fileDatesInfoOffset = prodosFileInfoOffset + 8;
int resourceForkOffset = fileDatesInfoOffset + 16;
int dataForkOffset = resourceForkOffset + (hasResourceFork ? resourceFork.length : 0);
writeFileHeader(outputStream, entries);
if (hasRealName) writeHeader(outputStream, 3, realNameOffset, realName.length());
writeHeader(outputStream, 11, prodosFileInfoOffset, 8);
writeHeader(outputStream, 8, fileDatesInfoOffset, 16);
if (hasResourceFork) writeHeader(outputStream, 2, resourceForkOffset, resourceFork.length);
writeHeader(outputStream, 1, dataForkOffset, dataFork.length);
if (hasRealName) writeRealName(outputStream);
writeProdosFileInfo(outputStream);
writeFileDatesInfo(outputStream);
if (hasResourceFork) writeResourceFork(outputStream);
writeDataFork(outputStream);
}
@ -149,7 +189,7 @@ public class AppleSingle {
final byte[] filler = new byte[16];
ByteBuffer buf = ByteBuffer.allocate(26).order(ByteOrder.BIG_ENDIAN);
buf.putInt(MAGIC_NUMBER);
buf.putInt(VERSION_NUMBER);
buf.putInt(VERSION_NUMBER2);
buf.put(filler);
buf.putShort((short)numberOfEntries);
outputStream.write(buf.array());
@ -171,6 +211,14 @@ public class AppleSingle {
buf.putInt(prodosFileInfo.auxType);
outputStream.write(buf.array());
}
private void writeFileDatesInfo(OutputStream outputStream) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN);
buf.putInt(fileDatesInfo.getCreation());
buf.putInt(fileDatesInfo.getModification());
buf.putInt(fileDatesInfo.getBackup());
buf.putInt(fileDatesInfo.getAccess());
outputStream.write(buf.array());
}
private void writeResourceFork(OutputStream outputStream) throws IOException {
outputStream.write(resourceFork);
}
@ -237,6 +285,41 @@ public class AppleSingle {
as.prodosFileInfo.auxType = auxType;
return this;
}
public Builder creationDate(int creation) {
as.fileDatesInfo.creation = creation;
return this;
}
public Builder creationDate(Instant creation) {
as.fileDatesInfo.creation = FileDatesInfo.fromInstant(creation);
return this;
}
public Builder modificationDate(int modification) {
as.fileDatesInfo.modification = modification;
return this;
}
public Builder modificationDate(Instant modification) {
as.fileDatesInfo.modification = FileDatesInfo.fromInstant(modification);
return this;
}
public Builder backupDate(int backup) {
as.fileDatesInfo.backup = backup;
return this;
}
public Builder backupDate(Instant backup) {
as.fileDatesInfo.backup = FileDatesInfo.fromInstant(backup);
return this;
}
public Builder accessDate(int access) {
as.fileDatesInfo.access = access;
return this;
}
public Builder accessDate(Instant access) {
as.fileDatesInfo.access = FileDatesInfo.fromInstant(access);
return this;
}
public Builder allDates(Instant instant) {
return creationDate(instant).modificationDate(instant).backupDate(instant).accessDate(instant);
}
public AppleSingle build() {
return as;
}

View File

@ -0,0 +1,66 @@
package io.github.applecommander.applesingle;
import java.time.Instant;
import java.util.function.IntSupplier;
public class FileDatesInfo {
/** The number of seconds at the begining of the AppleSingle date epoch since the Unix epoch began. */
//public static final int EPOCH_2000 = 946684800;
public static final Instant EPOCH_INSTANT = Instant.parse("2000-01-01T00:00:00.00Z");
/** Per the AppleSingle technical notes. */
public static final int UNKNOWN_DATE = 0x80000000;
int creation;
int modification;
int backup;
int access;
public static int fromInstant(Instant instant) {
return (int)(instant.getEpochSecond() - EPOCH_INSTANT.getEpochSecond());
}
public FileDatesInfo() {
int current = FileDatesInfo.fromInstant(Instant.now());
this.creation = current;
this.modification = current;
this.backup = current;
this.access = current;
}
public FileDatesInfo(int creation, int modification, int backup, int access) {
this.creation = creation;
this.modification = modification;
this.backup = backup;
this.access = access;
}
public Instant getCreationInstant() {
return toInstant(this::getCreation);
}
public Instant getModificationInstant() {
return toInstant(this::getModification);
}
public Instant getBackupInstant() {
return toInstant(this::getBackup);
}
public Instant getAccessInstant() {
return toInstant(this::getAccess);
}
/** Utility method to convert the int to a valid Unix epoch and Java Instant. */
public Instant toInstant(IntSupplier timeSupplier) {
return Instant.ofEpochSecond(timeSupplier.getAsInt() + EPOCH_INSTANT.getEpochSecond());
}
public int getCreation() {
return creation;
}
public int getModification() {
return modification;
}
public int getBackup() {
return backup;
}
public int getAccess() {
return access;
}
}

View File

@ -7,6 +7,8 @@ import static org.junit.Assert.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.junit.Test;
@ -32,11 +34,14 @@ public class AppleSingleTest {
public void testCreateAndReadAppleSingle() throws IOException {
final byte[] dataFork = "testing testing 1-2-3".getBytes();
final String realName = "test.as";
// Need to truncate to seconds as the AppleSingle format is only good to seconds!
final Instant instant = Instant.now().truncatedTo(ChronoUnit.SECONDS);
// Using default ProDOS info and skipping resource fork
AppleSingle createdAS = AppleSingle.builder()
.dataFork(dataFork)
.realName(realName)
.allDates(instant)
.build();
assertNotNull(createdAS);
assertEquals(realName.toUpperCase(), createdAS.getRealName());
@ -54,6 +59,11 @@ public class AppleSingleTest {
assertArrayEquals(dataFork, readAS.getDataFork());
assertNull(readAS.getResourceFork());
assertNotNull(readAS.getProdosFileInfo());
assertNotNull(readAS.getFileDatesInfo());
assertEquals(instant, readAS.getFileDatesInfo().getCreationInstant());
assertEquals(instant, readAS.getFileDatesInfo().getModificationInstant());
assertEquals(instant, readAS.getFileDatesInfo().getAccessInstant());
assertEquals(instant, readAS.getFileDatesInfo().getBackupInstant());
}
@Test

View File

@ -1,7 +1,7 @@
# Universal applesingle version number. Used for:
# - Naming JAR file.
# - The build will insert this into a file that is read at run time as well.
version=1.0.1
version=1.1.0
# Maven Central Repository G and A of GAV coordinate. :-)
group=net.sf.applecommander

View File

@ -0,0 +1,172 @@
package io.github.applecommander.applesingle.tools.asu;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import io.github.applecommander.applesingle.AppleSingle;
import io.github.applecommander.applesingle.FileDatesInfo;
import io.github.applecommander.applesingle.ProdosFileInfo;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Perform a bit of analysis of an AppleSingle archive to help extend the library, fix bugs,
* or understand incompatibilities.
*/
@Command(name = "analyze", description = { "Perform an analysis on an AppleSingle file",
"Please include a file name or indicate stdin should be read, but not both." },
parameterListHeading = "%nParameters:%n",
descriptionHeading = "%n",
optionListHeading = "%nOptions:%n")
public class AnalyzeCommand implements Callable<Void> {
@Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true)
private boolean helpFlag;
@Option(names = "--stdin", description = "Read AppleSingle from stdin.")
private boolean stdinFlag;
@Option(names = { "-v", "--verbose" }, description = "Be verbose.")
private boolean verboseFlag;
private PrintStream verbose = new PrintStream(NullOutputStream.INSTANCE);
@Parameters(arity = "0..1", description = "File to process")
private Path path;
@Override
public Void call() throws IOException {
byte[] fileData = stdinFlag ? AppleSingle.toByteArray(System.in) : Files.readAllBytes(path);
if (verboseFlag) this.verbose = System.out;
State state = new State(fileData);
match(state, "Magic number", "Not an AppleSingle file - magic number does not match.",
AppleSingle.MAGIC_NUMBER);
int version = match(state, "Version", "Only recognize AppleSingle versions 1 and 2.",
AppleSingle.VERSION_NUMBER1, AppleSingle.VERSION_NUMBER2);
verbose.printf(" .. Version 0x%08x\n", version);
state.read(16, "Filler");
int numberOfEntries = state.read(Short.BYTES, "Number of entries").getShort();
verbose.printf(" .. Entries = %d\n", numberOfEntries);
List<Entry> entries = new ArrayList<>();
for (int i = 0; i < numberOfEntries; i++) {
ByteBuffer buffer = state.read(12, String.format("Entry #%d", i+1));
Entry entry = new Entry(i+1, buffer);
entry.print(verbose);
entries.add(entry);
}
entries.sort((a,b) -> Integer.compare(a.offset, b.offset));
for (Entry entry : entries) entryReport(state, entry);
List<IntRange> ranges = IntRange.normalize(state.used);
if (ranges.size() == 1 && ranges.get(0).getLow() == 0 && ranges.get(0).getHigh() == fileData.length) {
verbose.printf("The entirety of the file was used.\n");
} else {
verbose.printf("Parts of the file were skipped!\n - Expected: %s\n - Actual: %s\n",
Arrays.asList(IntRange.of(0,fileData.length)), ranges);
}
return null;
}
public int match(State state, String description, String message, int... expecteds) throws IOException {
ByteBuffer buffer = state.read(Integer.BYTES, description);
int actual = buffer.getInt();
for (int expected : expecteds) {
if (actual == expected) return actual;
}
throw new IOException(String.format("%s Aborting.", message));
}
public void entryReport(State state, Entry entry) throws IOException {
String entryName = AppleSingle.ENTRY_TYPE_NAMES.getOrDefault(entry.entryId, "Unknown");
ByteBuffer buffer = state.readAt(entry.offset, entry.length,
String.format("Entry #%d data (%s)", entry.index, entryName));
switch (entry.entryId) {
case 3:
case 4:
case 13:
displayEntryString(buffer, entryName);
break;
case 8:
displayFileDatesInfo(buffer, entryName);
break;
case 11:
displayProdosFileInfo(buffer, entryName);
break;
default:
verbose.printf(" .. No further details for this entry type (%s).\n", entryName);
break;
}
}
public void displayEntryString(ByteBuffer buffer, String entryName) {
StringBuilder sb = new StringBuilder();
while (buffer.hasRemaining()) {
int ch = Byte.toUnsignedInt(buffer.get()) & 0x7f;
sb.append((char)ch);
}
verbose.printf(" .. %s: '%s'\n", entryName, sb.toString());
}
public void displayFileDatesInfo(ByteBuffer buffer, String entryName) {
FileDatesInfo info = new FileDatesInfo(buffer.getInt(), buffer.getInt(), buffer.getInt(), buffer.getInt());
verbose.printf(" .. %s -\n", entryName);
verbose.printf(" Creation: %s\n", info.getCreationInstant().toString());
verbose.printf(" Modification: %s\n", info.getModificationInstant().toString());
verbose.printf(" Backup: %s\n", info.getBackupInstant().toString());
verbose.printf(" Access: %s\n", info.getAccessInstant().toString());
}
public void displayProdosFileInfo(ByteBuffer buffer, String entryName) {
ProdosFileInfo info = new ProdosFileInfo(buffer.getShort(), buffer.getShort(), buffer.getInt());
verbose.printf(" .. %s -\n", entryName);
verbose.printf(" Access: %02X\n", info.getAccess());
verbose.printf(" File Type: %04X\n", info.getFileType());
verbose.printf(" Aux. Type: %04X\n", info.getAuxType());
}
public static class State {
private final byte[] data;
private int pos = 0;
private List<IntRange> used = new ArrayList<>();
private HexDumper dumper = HexDumper.standard();
public State(byte[] data) {
this.data = data;
}
public ByteBuffer read(int len, String description) throws IOException {
return readAt(pos, len, description);
}
public ByteBuffer readAt(int start, int len, String description) throws IOException {
byte[] chunk = new byte[len];
System.arraycopy(data, start, chunk, 0, len);
ByteBuffer buffer = ByteBuffer.wrap(chunk)
.order(ByteOrder.BIG_ENDIAN)
.asReadOnlyBuffer();
dumper.dump(start, chunk, description);
used.add(IntRange.of(start, start+len));
pos= start+len;
return buffer;
}
}
public static class Entry {
private int index;
private int entryId;
private int offset;
private int length;
public Entry(int index, ByteBuffer buffer) {
this.index = index;
this.entryId = buffer.getInt();
this.offset = buffer.getInt();
this.length = buffer.getInt();
}
public void print(PrintStream ps) {
ps.printf(" .. Entry #%d, entryId=%d (%s), offset=%d, length=%d\n", index, entryId,
AppleSingle.ENTRY_TYPE_NAMES.getOrDefault(entryId, "Unknown"), offset, length);
}
}
}

View File

@ -3,6 +3,9 @@ package io.github.applecommander.applesingle.tools.asu;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.Callable;
import io.github.applecommander.applesingle.AppleSingle;
@ -16,6 +19,10 @@ import picocli.CommandLine.Parameters;
@Command(name = "create", description = { "Create an AppleSingle file" },
parameterListHeading = "%nParameters:%n",
descriptionHeading = "%n",
footerHeading = "%nNotes:%n",
footer = { "* Dates should be supplied like '2007-12-03T10:15:30.00Z'.",
"* 'Known' ProDOS file types: TXT, BIN, INT, BAS, REL, SYS.",
"* Include the output file or specify stdout" },
optionListHeading = "%nOptions:%n")
public class CreateCommand implements Callable<Void> {
@Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true)
@ -42,11 +49,20 @@ public class CreateCommand implements Callable<Void> {
@Option(names = "--access", description = "Set the ProDOS access flags", converter = IntegerTypeConverter.class)
private Integer access;
@Option(names = "--filetype", description = "Set the ProDOS file type (also accepts BIN/BAS/SYS)", converter = ProdosFileTypeConverter.class)
@Option(names = "--filetype", description = "Set the ProDOS file type", converter = ProdosFileTypeConverter.class)
private Integer filetype;
@Option(names = "--auxtype", description = "Set the ProDOS auxtype", converter = IntegerTypeConverter.class)
private Integer auxtype;
@Option(names = "--creation-date", description = "Set the file creation date")
private Instant creationDate;
@Option(names = "--modification-date", description = "Set the file modification date")
private Instant modificationDate;
@Option(names = "--backup-date", description = "Set the file backup date")
private Instant backupDate;
@Option(names = "--access-date", description = "Set the file access date")
private Instant accessDate;
@Parameters(arity = "0..1", description = "AppleSingle file to create")
private Path file;
@ -107,7 +123,7 @@ public class CreateCommand implements Callable<Void> {
return resourceFork;
}
public AppleSingle buildAppleSingle(byte[] dataFork, byte[] resourceFork) {
public AppleSingle buildAppleSingle(byte[] dataFork, byte[] resourceFork) throws IOException {
AppleSingle.Builder builder = AppleSingle.builder();
if (realName != null) {
builder.realName(realName);
@ -121,6 +137,18 @@ public class CreateCommand implements Callable<Void> {
if (dataFork != null) builder.dataFork(dataFork);
if (resourceFork != null) builder.resourceFork(resourceFork);
if (dataForkFile != null || resourceForkFile != null) {
Path path = Optional.ofNullable(dataForkFile).orElse(resourceForkFile);
BasicFileAttributes attribs = Files.readAttributes(path, BasicFileAttributes.class);
builder.creationDate(attribs.creationTime().toInstant());
builder.modificationDate(attribs.lastModifiedTime().toInstant());
builder.accessDate(attribs.lastAccessTime().toInstant());
}
if (creationDate != null) builder.creationDate(creationDate);
if (modificationDate != null) builder.modificationDate(modificationDate);
if (backupDate != null) builder.backupDate(backupDate);
if (accessDate != null) builder.accessDate(accessDate);
return builder.build();
}

View File

@ -0,0 +1,62 @@
package io.github.applecommander.applesingle.tools.asu;
import java.io.PrintStream;
import java.util.Arrays;
/** A slightly-configurable reusable hex dumping mechanism. */
public class HexDumper {
private PrintStream ps = System.out;
private int lineWidth = 16;
private LinePrinter printLine;
public static HexDumper standard() {
HexDumper hd = new HexDumper();
hd.printLine = hd::standardLine;
return hd;
}
public static HexDumper alternate(LinePrinter linePrinter) {
HexDumper hd = new HexDumper();
hd.printLine = linePrinter;
return hd;
}
private HexDumper() {
// Prevent construction
}
public void dump(int address, byte[] data, String description) {
int offset = 0;
while (offset < data.length) {
byte[] line = Arrays.copyOfRange(data, offset, Math.min(offset+lineWidth,data.length));
printLine.print(address+offset, line, description);
description = ""; // Only on first line!
offset += line.length;
}
}
public void standardLine(int address, byte[] data, String description) {
ps.printf("%04x: ", address);
for (int i=0; i<lineWidth; i++) {
if (i < data.length) {
ps.printf("%02x ", data[i]);
} else {
ps.printf(".. ");
}
}
ps.print("| ");
for (int i=0; i<lineWidth; i++) {
char ch = ' ';
if (i < data.length) {
byte b = data[i];
ch = (b >= ' ') ? (char)b : '.';
}
ps.printf("%c", ch);
}
ps.printf(" | %s\n", description);
}
@FunctionalInterface
public interface LinePrinter {
public void print(int address, byte[] data, String description);
}
}

View File

@ -0,0 +1,66 @@
package io.github.applecommander.applesingle.tools.asu;
import java.util.List;
import java.util.Optional;
import java.util.Stack;
/**
* A basic integer range used to track file usage.
* <code>low</code> is inclusive while <code>high</code> is exclusive, because it made
* the code "simpler".
*
* @author rob
*/
public class IntRange {
private int low;
private int high;
/** Create an integer range. */
public static IntRange of(int low, int high) {
if (low == high) throw new UnsupportedOperationException("low and high cannot be the same");
return new IntRange(Math.min(low,high), Math.max(low,high));
}
/** Normalize a list by combining all integer ranges that match. */
public static List<IntRange> normalize(List<IntRange> ranges) {
Stack<IntRange> rangeStack = new Stack<>();
ranges.stream()
.sorted((a,b) -> Integer.compare(a.low, b.low))
.forEach(r -> {
if (rangeStack.isEmpty()) {
rangeStack.add(r);
} else {
rangeStack.peek()
.merge(r)
.ifPresent(ranges::add);
}
});
return rangeStack;
}
private IntRange(int low, int high) {
this.low = low;
this.high = high;
}
public int getLow() {
return low;
}
public int getHigh() {
return high;
}
/** Merge the other IntRange into this one, if it fits. */
public Optional<IntRange> merge(IntRange other) {
if (this.high == other.low) {
this.high = other.high;
return Optional.empty();
} else if (this.low == other.high) {
this.low = other.low;
return Optional.empty();
} else {
return Optional.of(other);
}
}
@Override
public String toString() {
return String.format("[%d..%d)", low, high);
}
}

View File

@ -15,7 +15,7 @@ import picocli.CommandLine.Option;
commandListHeading = "%nCommands:%n",
optionListHeading = "%nOptions:%n",
description = "AppleSingle utility",
subcommands = { HelpCommand.class, InfoCommand.class, CreateCommand.class, ExtractCommand.class })
subcommands = { HelpCommand.class, InfoCommand.class, AnalyzeCommand.class, CreateCommand.class, ExtractCommand.class })
public class Main implements Runnable {
@Option(names = "--debug", description = "Dump full stack trackes if an error occurs")
private static boolean debugFlag;

View File

@ -0,0 +1,16 @@
package io.github.applecommander.applesingle.tools.asu;
import java.io.IOException;
import java.io.OutputStream;
/** An OutputStream that doesn't do output. */
public class NullOutputStream extends OutputStream {
public static final NullOutputStream INSTANCE = new NullOutputStream();
private NullOutputStream() { /* Prevent construction */ }
@Override
public void write(int b) throws IOException {
// Do Nothing
}
}