|
| 1 | +/* |
| 2 | + * Copyright (c) 2022 airsquared |
| 3 | + * |
| 4 | + * This file is part of blobsaver. |
| 5 | + * |
| 6 | + * blobsaver is free software: you can redistribute it and/or modify |
| 7 | + * it under the terms of the GNU General Public License as published by |
| 8 | + * the Free Software Foundation, version 3 of the License. |
| 9 | + * |
| 10 | + * blobsaver is distributed in the hope that it will be useful, |
| 11 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 | + * GNU General Public License for more details. |
| 14 | + * |
| 15 | + * You should have received a copy of the GNU General Public License |
| 16 | + * along with blobsaver. If not, see <https://www.gnu.org/licenses/>. |
| 17 | + */ |
| 18 | + |
| 19 | +package airsquared.blobsaver.app; |
| 20 | + |
| 21 | +import picocli.CommandLine; |
| 22 | +import picocli.CommandLine.*; |
| 23 | +import picocli.CommandLine.Model.ArgSpec; |
| 24 | +import picocli.CommandLine.Model.CommandSpec; |
| 25 | +import picocli.CommandLine.Model.OptionSpec; |
| 26 | + |
| 27 | +import java.io.File; |
| 28 | +import java.io.IOException; |
| 29 | +import java.io.PrintWriter; |
| 30 | +import java.util.HashSet; |
| 31 | +import java.util.concurrent.Callable; |
| 32 | +import java.util.prefs.InvalidPreferencesFormatException; |
| 33 | +import java.util.stream.Collectors; |
| 34 | + |
| 35 | +@Command(name = "blobsaver", version = {CLI.warning, Main.appVersion}, header = CLI.warning, |
| 36 | + optionListHeading = " You can separate options and their parameters with either a space or '='.%n", |
| 37 | + mixinStandardHelpOptions = true, sortOptions = false, sortSynopsis = false, usageHelpAutoWidth = true, abbreviateSynopsis = true) |
| 38 | +public class CLI implements Callable<Void> { |
| 39 | + |
| 40 | + public static final String warning = "Warning: blobsaver's CLI is in alpha. Commands, options, and exit codes may change at any time.%n"; |
| 41 | + |
| 42 | + @Option(names = {"-s", "--save-blobs"}) |
| 43 | + boolean saveBlobs; |
| 44 | + |
| 45 | + @Option(names = "--save-device", paramLabel = "<name>", description = "Create a new saved device.") |
| 46 | + String saveDevice; |
| 47 | + |
| 48 | + @Option(names = "--remove-device", paramLabel = "<Saved Device>", description = "Remove a saved device.") |
| 49 | + Prefs.SavedDevice removeDevice; |
| 50 | + |
| 51 | + @Option(names = "--enable-background", paramLabel = "<Saved Device>", description = "Enable background saving for a device.%nUse '--start-background-service' once devices are added.") |
| 52 | + Prefs.SavedDevice enableBackground; |
| 53 | + |
| 54 | + @Option(names = "--disable-background", paramLabel = "<Saved Device>", description = "Disable background saving for a device.") |
| 55 | + Prefs.SavedDevice disableBackground; |
| 56 | + |
| 57 | + @ArgGroup |
| 58 | + BackgroundControls backgroundControls; |
| 59 | + static class BackgroundControls { |
| 60 | + @Option(names = "--start-background-service", description = "Register background saving service with the OS.") |
| 61 | + boolean startBackground; |
| 62 | + @Option(names = "--stop-background-service", description = "Deregister background saving service from the OS.") |
| 63 | + boolean stopBackground; |
| 64 | + |
| 65 | + @Option(names = "--background-autosave", description = "Save blobs for all devices configured to save in background. All other options have no effect on this option. Useful in scripts.") |
| 66 | + boolean backgroundAutosave; |
| 67 | + } |
| 68 | + |
| 69 | + @Option(names = "--export", paramLabel = "<path>", description = "Export saved devices in XML format to the directory.") |
| 70 | + File exportPath; |
| 71 | + |
| 72 | + @Option(names = "--import", paramLabel = "<path>", description = "Import saved devices from a blobsaver XML file.") |
| 73 | + File importPath; |
| 74 | + |
| 75 | + @Option(names = "--identifier") |
| 76 | + String device; |
| 77 | + @Option(names = "--ecid") |
| 78 | + String ecid; |
| 79 | + |
| 80 | + @Option(names = "--boardconfig") |
| 81 | + String boardConfig; |
| 82 | + |
| 83 | + @Option(names = "--apnonce") |
| 84 | + String apnonce; |
| 85 | + |
| 86 | + @Option(names = "--generator") |
| 87 | + String generator; |
| 88 | + |
| 89 | + @Option(names = "--save-path", paramLabel = "<path>", |
| 90 | + description = "Directory to save blobs in. Can use the following variables: " + |
| 91 | + "$${DeviceIdentifier}, $${BoardConfig}, $${APNonce}, $${Generator}, $${DeviceModel}, $${ECID}, $${FullVersionString}, $${BuildID}, and $${MajorVersion}.") |
| 92 | + File savePath; |
| 93 | + |
| 94 | + @ArgGroup |
| 95 | + Version version; |
| 96 | + static class Version { |
| 97 | + @Option(names = "--ios-version", paramLabel = "<version>") |
| 98 | + String manualVersion; |
| 99 | + @Option(names = "--ipsw-url", paramLabel = "<url>", description = "Either a URL to an IPSW file or a build manifest. Local 'file:' URLs are accepted.") |
| 100 | + String manualIpswURL; |
| 101 | + |
| 102 | + @Option(names = {"-b", "--include-betas"}) |
| 103 | + boolean includeBetas; |
| 104 | + } |
| 105 | + |
| 106 | + @Spec CommandSpec spec; |
| 107 | + |
| 108 | + @Override |
| 109 | + public Void call() throws TSS.TSSException, IOException, InvalidPreferencesFormatException { |
| 110 | + if (importPath != null) { |
| 111 | + Prefs.importXML(importPath); |
| 112 | + System.out.println("Successfully imported saved devices."); |
| 113 | + } |
| 114 | + if (saveBlobs) { |
| 115 | + checkArgs("identifier", "ecid", "save-path"); |
| 116 | + var tss = new TSS.Builder() |
| 117 | + .setDevice(device).setEcid(ecid).setSavePath(savePath.getCanonicalPath()).setBoardConfig(boardConfig) |
| 118 | + .setManualVersion(version.manualVersion).setManualIpswURL(version.manualIpswURL).setApnonce(apnonce) |
| 119 | + .setGenerator(generator).setIncludeBetas(version.includeBetas).build(); |
| 120 | + System.out.println(tss.call()); |
| 121 | + } |
| 122 | + if (removeDevice != null) { |
| 123 | + removeDevice.delete(); |
| 124 | + System.out.println("Deleted " + removeDevice + "."); |
| 125 | + } |
| 126 | + if (saveDevice != null) { |
| 127 | + checkArgs("ecid", "save-path", "identifier"); |
| 128 | + var saved = new Prefs.SavedDeviceBuilder(saveDevice) |
| 129 | + .setIdentifier(device).setEcid(ecid).setSavePath(savePath.getCanonicalPath()).setBoardConfig(boardConfig) |
| 130 | + .setApnonce(apnonce).setGenerator(generator).setIncludeBetas(version.includeBetas).save(); |
| 131 | + System.out.println("Saved " + saved + "."); |
| 132 | + } |
| 133 | + if (enableBackground != null) { |
| 134 | + if (!saveBlobs) { |
| 135 | + System.out.println("Testing device\n"); |
| 136 | + Background.saveBlobs(enableBackground); |
| 137 | + } |
| 138 | + enableBackground.setBackground(true); |
| 139 | + System.out.println("Enabled background for " + enableBackground + "."); |
| 140 | + } |
| 141 | + if (disableBackground != null) { |
| 142 | + disableBackground.setBackground(false); |
| 143 | + System.out.println("Disabled background for " + enableBackground + "."); |
| 144 | + } |
| 145 | + if (backgroundControls.startBackground) { |
| 146 | + Background.startBackground(); |
| 147 | + if (Background.isBackgroundEnabled()) { |
| 148 | + System.out.println("A background saving task has been scheduled."); |
| 149 | + } else { |
| 150 | + throw new ExecutionException(spec.commandLine(), "Error: Unable to enable background saving."); |
| 151 | + } |
| 152 | + } else if (backgroundControls.stopBackground) { |
| 153 | + Background.stopBackground(); |
| 154 | + } else if (backgroundControls.backgroundAutosave) { |
| 155 | + Background.saveAllBackgroundBlobs(); |
| 156 | + } |
| 157 | + if (exportPath != null) { |
| 158 | + Prefs.export(exportPath); |
| 159 | + System.out.println("Successfully exported saved devices."); |
| 160 | + } |
| 161 | + return null; |
| 162 | + } |
| 163 | + |
| 164 | + private void checkArgs(String... names) { |
| 165 | + var missing = new HashSet<ArgSpec>(); |
| 166 | + for (String name : names) { |
| 167 | + if (!spec.commandLine().getParseResult().hasMatchedOption(name)) { |
| 168 | + missing.add(spec.findOption(name)); |
| 169 | + } |
| 170 | + } |
| 171 | + if (!missing.isEmpty()) { |
| 172 | + String miss = missing.stream().map(OptionSpec.class::cast) |
| 173 | + .map(OptionSpec::longestName).collect(Collectors.joining(", ")); |
| 174 | + throw new MissingParameterException(spec.commandLine(), missing, "Missing required options: " + miss); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + private static Prefs.SavedDevice savedDeviceConverter(String name) { |
| 179 | + return Prefs.getSavedDevices().stream() |
| 180 | + .filter(savedDevice -> savedDevice.getName().equalsIgnoreCase(name)) |
| 181 | + .findAny().orElseThrow(() -> new TypeConversionException("Must be one of " + Prefs.getSavedDevices() + "\n")); |
| 182 | + } |
| 183 | + |
| 184 | + public static int handleExecutionException(Exception ex, CommandLine cmd, ParseResult parseResult) throws Exception { |
| 185 | + boolean messageOnly = ex instanceof ExecutionException |
| 186 | + // if either the exception is not reportable or there is a tssLog present |
| 187 | + || ex instanceof TSS.TSSException e && (!e.isReportable || e.tssLog != null); |
| 188 | + if (messageOnly) { |
| 189 | + cmd.getErr().println(cmd.getColorScheme().errorText(ex.getMessage())); |
| 190 | + |
| 191 | + return cmd.getExitCodeExceptionMapper() != null |
| 192 | + ? cmd.getExitCodeExceptionMapper().getExitCode(ex) |
| 193 | + : cmd.getCommandSpec().exitCodeOnExecutionException(); |
| 194 | + } |
| 195 | + throw ex; |
| 196 | + } |
| 197 | + |
| 198 | + public static int handleParseException(ParameterException ex, String[] args) { |
| 199 | + CommandLine cmd = ex.getCommandLine(); |
| 200 | + PrintWriter err = cmd.getErr(); |
| 201 | + |
| 202 | + // if tracing at DEBUG level, show the location of the issue |
| 203 | + if ("DEBUG".equalsIgnoreCase(System.getProperty("picocli.trace"))) { |
| 204 | + err.println(cmd.getColorScheme().stackTraceText(ex)); |
| 205 | + } |
| 206 | + |
| 207 | + err.println(cmd.getColorScheme().errorText(ex.getMessage())); // bold red |
| 208 | + UnmatchedArgumentException.printSuggestions(ex, err); |
| 209 | + err.print(cmd.getHelp().fullSynopsis()); |
| 210 | + |
| 211 | + CommandSpec spec = cmd.getCommandSpec(); |
| 212 | + err.printf("Try '%s --help' for more information.%n", spec.qualifiedName()); |
| 213 | + |
| 214 | + return cmd.getExitCodeExceptionMapper() != null |
| 215 | + ? cmd.getExitCodeExceptionMapper().getExitCode(ex) |
| 216 | + : spec.exitCodeOnInvalidInput(); |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * @return the exit code |
| 221 | + */ |
| 222 | + public static int launch(String... args) { |
| 223 | + var c = new CommandLine(new CLI()) |
| 224 | + .setExecutionExceptionHandler(CLI::handleExecutionException) |
| 225 | + .setParameterExceptionHandler(CLI::handleParseException) |
| 226 | + .registerConverter(Prefs.SavedDevice.class, CLI::savedDeviceConverter); |
| 227 | + return c.execute(args); |
| 228 | + } |
| 229 | +} |
0 commit comments