Add: support for user-global configuration.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package dev.photosync.core;
|
||||
|
||||
import dev.photosync.core.config.ConfigLocation;
|
||||
import dev.photosync.core.config.ConfigLocations;
|
||||
import dev.photosync.core.config.ConfigService;
|
||||
import dev.photosync.core.config.ConfigStore;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
@@ -14,7 +16,10 @@ import dev.photosync.core.upload.UploadQueue;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
@@ -22,9 +27,9 @@ import java.util.List;
|
||||
* Everything the mod does that is not Minecraft, assembled.
|
||||
*
|
||||
* <p>This is the seam the platform code sees: a Fabric entrypoint builds one of
|
||||
* these with a config directory and then only ever talks to the services hanging
|
||||
* off it. Nothing below this package knows what a {@code Screen} is, and nothing
|
||||
* here reaches back up.
|
||||
* these with a {@link ConfigLocations} -- where the settings file may live --
|
||||
* and then only ever talks to the services hanging off it. Nothing below this
|
||||
* package knows what a {@code Screen} is, and nothing here reaches back up.
|
||||
*
|
||||
* <p>The wiring is done in a constructor rather than by a container because
|
||||
* there are seven objects and their order is fixed. The one piece of behaviour
|
||||
@@ -37,6 +42,10 @@ public final class PhotoSync implements AutoCloseable {
|
||||
private static final String CONFIG_FILE = "photosync.json";
|
||||
private static final String QUEUE_FILE = "uploads.json";
|
||||
|
||||
@Getter
|
||||
private final ConfigLocations locations;
|
||||
private volatile ConfigLocation activeLocation;
|
||||
|
||||
@Getter
|
||||
private final ProviderCatalog catalog;
|
||||
@Getter
|
||||
@@ -52,19 +61,33 @@ public final class PhotoSync implements AutoCloseable {
|
||||
@Getter
|
||||
private final ThumbnailLoader thumbnails;
|
||||
|
||||
/** The standard set of backends. */
|
||||
/** The standard set of backends, stored in the given directory. */
|
||||
public PhotoSync(Path directory) {
|
||||
this(directory, List.of(new ImmichProviderFactory()));
|
||||
this(ConfigLocations.fixed(directory), List.of(new ImmichProviderFactory()));
|
||||
}
|
||||
|
||||
/** Takes the backend list explicitly so tests can run against a fake one. */
|
||||
public PhotoSync(Path directory, List<ProviderFactory> providers) {
|
||||
this(ConfigLocations.fixed(directory), providers);
|
||||
}
|
||||
|
||||
/** The standard set of backends, with a switchable storage location. */
|
||||
public PhotoSync(ConfigLocations locations) {
|
||||
this(locations, List.of(new ImmichProviderFactory()));
|
||||
}
|
||||
|
||||
/** Takes the backend list explicitly so tests can run against a fake one. */
|
||||
public PhotoSync(ConfigLocations locations, List<ProviderFactory> providers) {
|
||||
this.locations = locations;
|
||||
this.activeLocation = locations.resolve();
|
||||
this.catalog = new ProviderCatalog(providers);
|
||||
this.config = new ConfigService(new ConfigStore(directory.resolve(CONFIG_FILE), catalog.preferred()));
|
||||
this.config = new ConfigService(new ConfigStore(
|
||||
locations.directory(activeLocation).resolve(CONFIG_FILE), catalog.preferred()));
|
||||
this.session = new ProviderSession(catalog);
|
||||
// Reads and repairs the on-disk queue, so anything interrupted by the
|
||||
// last quit is already pending again by the time uploads start.
|
||||
this.queue = new UploadQueue(directory.resolve(QUEUE_FILE));
|
||||
// The queue stays in the instance directory even when the settings file
|
||||
// moves: its entries reference screenshot paths that are local to the
|
||||
// instance, so a shared queue would point at the wrong files.
|
||||
this.queue = new UploadQueue(locations.directory(ConfigLocation.INSTANCE).resolve(QUEUE_FILE));
|
||||
this.uploads = new UploadCoordinator(queue, session, () -> config.current().upload());
|
||||
this.browser = new TimelineBrowser(session);
|
||||
this.thumbnails = new ThumbnailLoader(session);
|
||||
@@ -73,7 +96,9 @@ public final class PhotoSync implements AutoCloseable {
|
||||
/** Connects to the configured backend and starts draining the queue. */
|
||||
public PhotoSync start() {
|
||||
config.onChange(this::applyConnection);
|
||||
config.onChange(this::applyLocation);
|
||||
applyConnection(config.current());
|
||||
reconcileLocation();
|
||||
uploads.start();
|
||||
int resumed = queue.activeCount();
|
||||
if (resumed > 0) {
|
||||
@@ -103,6 +128,61 @@ public final class PhotoSync implements AutoCloseable {
|
||||
uploads.wake();
|
||||
}
|
||||
|
||||
/** Where the settings file currently lives. */
|
||||
public Path dataDirectory() {
|
||||
return locations.directory(activeLocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the settings file when the player switches storage location.
|
||||
*
|
||||
* <p>The upload queue is deliberately left in the instance directory; only
|
||||
* the settings file follows the choice. The marker is written last, so a
|
||||
* failure part-way leaves the old location authoritative for the next
|
||||
* launch.
|
||||
*/
|
||||
private void applyLocation(PhotoSyncConfig current) {
|
||||
ConfigLocation next = current.location();
|
||||
if (next == activeLocation) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path from = locations.directory(activeLocation);
|
||||
Path to = locations.directory(next);
|
||||
if (from.equals(to)) {
|
||||
// A catalog with no real user directory (tests): nothing to move.
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(to);
|
||||
moveFile(from.resolve(CONFIG_FILE), to.resolve(CONFIG_FILE));
|
||||
config.relocate(to.resolve(CONFIG_FILE));
|
||||
locations.writeMarker(next);
|
||||
activeLocation = next;
|
||||
log.info("PhotoSync settings moved to {}", to);
|
||||
} catch (IOException e) {
|
||||
log.error("Could not move PhotoSync settings to {}; keeping them in {}",
|
||||
locations.directory(next), locations.directory(activeLocation), e);
|
||||
config.update(previous -> previous.toBuilder().location(activeLocation).build());
|
||||
}
|
||||
}
|
||||
|
||||
private static void moveFile(Path source, Path target) throws IOException {
|
||||
if (Files.exists(source)) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the settings file's own location field in line with the marker.
|
||||
* They can only disagree when the file was hand-edited; the marker is the
|
||||
* truth about where the file actually is.
|
||||
*/
|
||||
private void reconcileLocation() {
|
||||
if (config.current().location() != activeLocation) {
|
||||
config.update(previous -> previous.toBuilder().location(activeLocation).build());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
thumbnails.close();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
/** Where PhotoSync keeps its settings file. */
|
||||
public enum ConfigLocation {
|
||||
|
||||
/** {@code config/photosync} inside the game directory -- the historical default, per instance. */
|
||||
INSTANCE,
|
||||
|
||||
/** The OS-user-level directory, shared across Minecraft installs. */
|
||||
USER
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.photosync.core.persistence.JsonFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The two places PhotoSync's settings file may live, and the record of which
|
||||
* one is active.
|
||||
*
|
||||
* <p>Which location is active cannot be stored inside the settings file itself,
|
||||
* because the mod has to know where that file is before it can read it. So the
|
||||
* choice is recorded in a small marker ({@code location.json}) that always
|
||||
* lives in the instance directory: start-up reads the marker to pick a
|
||||
* directory, and the settings screen rewrites it when the player switches. A
|
||||
* missing or unreadable marker means the instance directory, which is also what
|
||||
* every existing installation has, so nothing changes for them.
|
||||
*/
|
||||
public final class ConfigLocations {
|
||||
|
||||
private static final String MARKER_FILE = "location.json";
|
||||
|
||||
private final Path instanceDirectory;
|
||||
private final Path userDirectory;
|
||||
private final JsonFile marker;
|
||||
|
||||
public ConfigLocations(Path instanceDirectory, Path userDirectory) {
|
||||
this.instanceDirectory = instanceDirectory;
|
||||
this.userDirectory = userDirectory;
|
||||
this.marker = new JsonFile(instanceDirectory.resolve(MARKER_FILE));
|
||||
}
|
||||
|
||||
/** A catalog that offers no user directory, for tests and the plain constructor. */
|
||||
public static ConfigLocations fixed(Path directory) {
|
||||
return new ConfigLocations(directory, directory);
|
||||
}
|
||||
|
||||
/** The active location: what the marker says, or the instance when it says nothing. */
|
||||
public ConfigLocation resolve() {
|
||||
JsonElement stored = marker.readTree().orElse(null);
|
||||
if (stored == null || !stored.isJsonObject()) {
|
||||
return ConfigLocation.INSTANCE;
|
||||
}
|
||||
JsonElement value = stored.getAsJsonObject().get("location");
|
||||
if (value == null || !value.isJsonPrimitive()) {
|
||||
return ConfigLocation.INSTANCE;
|
||||
}
|
||||
try {
|
||||
return ConfigLocation.valueOf(value.getAsString());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// A hand-edited marker names something that is not a location.
|
||||
return ConfigLocation.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory a location maps to. */
|
||||
public Path directory(ConfigLocation location) {
|
||||
return location == ConfigLocation.USER ? userDirectory : instanceDirectory;
|
||||
}
|
||||
|
||||
/** Records the active location so the next launch looks in the right place. */
|
||||
public void writeMarker(ConfigLocation location) throws IOException {
|
||||
JsonObject written = new JsonObject();
|
||||
written.addProperty("location", location.name());
|
||||
marker.write(written);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dev.photosync.core.config;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -61,4 +62,9 @@ public final class ConfigService {
|
||||
log.error("Could not write {}", store.path(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Points the store at a new file, after the settings file has been moved there. */
|
||||
public void relocate(Path path) {
|
||||
store.relocate(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.Optional;
|
||||
@Slf4j
|
||||
public final class ConfigStore {
|
||||
|
||||
private final JsonFile file;
|
||||
private JsonFile file;
|
||||
private final ProviderId fallbackProvider;
|
||||
|
||||
public ConfigStore(Path path, ProviderId fallbackProvider) {
|
||||
@@ -37,6 +37,11 @@ public final class ConfigStore {
|
||||
return file.path();
|
||||
}
|
||||
|
||||
/** Points the store at a new file, after the settings file has been moved there. */
|
||||
public void relocate(Path path) {
|
||||
this.file = new JsonFile(path);
|
||||
}
|
||||
|
||||
/** Never throws: a broken config falls back to defaults rather than blocking start-up. */
|
||||
public PhotoSyncConfig load() {
|
||||
PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider);
|
||||
|
||||
@@ -21,7 +21,8 @@ public record PhotoSyncConfig(
|
||||
UploadSettings upload,
|
||||
AutoCaptureSettings autoCapture,
|
||||
NotificationSettings notifications,
|
||||
BrowserSettings browser) {
|
||||
BrowserSettings browser,
|
||||
ConfigLocation location) {
|
||||
|
||||
public static PhotoSyncConfig defaults(ProviderId provider) {
|
||||
return PhotoSyncConfig.builder()
|
||||
@@ -32,6 +33,7 @@ public record PhotoSyncConfig(
|
||||
.autoCapture(AutoCaptureSettings.defaults())
|
||||
.notifications(NotificationSettings.defaults())
|
||||
.browser(BrowserSettings.defaults())
|
||||
.location(ConfigLocation.INSTANCE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -49,6 +51,7 @@ public record PhotoSyncConfig(
|
||||
.autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized())
|
||||
.notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized())
|
||||
.browser((browser == null ? BrowserSettings.defaults() : browser).normalized())
|
||||
.location(location == null ? ConfigLocation.INSTANCE : location)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The marker that decides where the settings file lives. */
|
||||
class ConfigLocationsTest {
|
||||
|
||||
@TempDir
|
||||
Path temp;
|
||||
|
||||
@Test
|
||||
void resolvesTheInstanceDirectoryWhenThereIsNoMarker() {
|
||||
ConfigLocations locations = new ConfigLocations(temp.resolve("instance"), temp.resolve("user"));
|
||||
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
|
||||
assertEquals(temp.resolve("instance"), locations.directory(ConfigLocation.INSTANCE));
|
||||
assertEquals(temp.resolve("user"), locations.directory(ConfigLocation.USER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remembersTheChoiceAcrossInstances() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
|
||||
locations.writeMarker(ConfigLocation.USER);
|
||||
|
||||
// A fresh PhotoSync reads the same marker and arrives at the same answer.
|
||||
ConfigLocations reopened = new ConfigLocations(instance, temp.resolve("user"));
|
||||
assertEquals(ConfigLocation.USER, reopened.resolve());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheInstanceDirectoryForAGarbageMarker() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
Files.createDirectories(instance);
|
||||
Files.writeString(instance.resolve("location.json"), "not json");
|
||||
|
||||
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
|
||||
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheInstanceDirectoryForAnUnknownLocation() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
Files.createDirectories(instance);
|
||||
Files.writeString(instance.resolve("location.json"), "{\"location\":\"elsewhere\"}");
|
||||
|
||||
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
|
||||
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedCatalogTreatsTheUserDirectoryAsTheInstanceDirectory() {
|
||||
ConfigLocations locations = ConfigLocations.fixed(temp.resolve("dir"));
|
||||
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
|
||||
assertEquals(locations.directory(ConfigLocation.INSTANCE), locations.directory(ConfigLocation.USER));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import dev.photosync.core.PhotoSync;
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.immich.ImmichProviderFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Switching the storage location really moves the settings file, and nothing else. */
|
||||
class PhotoSyncRelocationTest {
|
||||
|
||||
@TempDir
|
||||
Path temp;
|
||||
|
||||
private ConfigLocations locations() {
|
||||
return new ConfigLocations(temp.resolve("instance"), temp.resolve("user"));
|
||||
}
|
||||
|
||||
private PhotoSync started(ConfigLocations locations) {
|
||||
PhotoSync core = new PhotoSync(locations, List.of(new ImmichProviderFactory()));
|
||||
core.start();
|
||||
return core;
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesSettingsFileAndRemembersTheChoice() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
Path user = temp.resolve("user");
|
||||
ConfigLocations locations = locations();
|
||||
|
||||
PhotoSync core = started(locations);
|
||||
core.config().update(current -> current.toBuilder().albumId("first").build());
|
||||
assertTrue(Files.exists(instance.resolve("photosync.json")));
|
||||
|
||||
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
|
||||
|
||||
// The file followed the setting, and the marker says where it went.
|
||||
assertFalse(Files.exists(instance.resolve("photosync.json")));
|
||||
assertTrue(Files.exists(user.resolve("photosync.json")));
|
||||
assertEquals(ConfigLocation.USER, locations.resolve());
|
||||
assertEquals(ConfigLocation.USER, core.config().current().location());
|
||||
assertEquals(user, core.dataDirectory());
|
||||
|
||||
// Later saves land in the new place.
|
||||
core.config().update(current -> current.toBuilder().albumId("second").build());
|
||||
assertTrue(Files.readString(user.resolve("photosync.json")).contains("\"second\""));
|
||||
|
||||
core.close();
|
||||
|
||||
// A fresh PhotoSync resolves the marker and reads the moved file.
|
||||
PhotoSync reopened = started(locations);
|
||||
assertEquals(ConfigLocation.USER, reopened.config().current().location());
|
||||
assertEquals("second", reopened.config().current().albumId());
|
||||
reopened.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchesBackToTheInstanceDirectory() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
Path user = temp.resolve("user");
|
||||
ConfigLocations locations = locations();
|
||||
|
||||
PhotoSync core = started(locations);
|
||||
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
|
||||
assertTrue(Files.exists(user.resolve("photosync.json")));
|
||||
|
||||
core.config().update(current -> current.toBuilder().location(ConfigLocation.INSTANCE).build());
|
||||
|
||||
assertTrue(Files.exists(instance.resolve("photosync.json")));
|
||||
assertFalse(Files.exists(user.resolve("photosync.json")));
|
||||
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
|
||||
assertEquals(instance, core.dataDirectory());
|
||||
core.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesTheUploadQueueInTheInstanceDirectory() throws Exception {
|
||||
Path instance = temp.resolve("instance");
|
||||
Path user = temp.resolve("user");
|
||||
Path shot = temp.resolve("screenshot.png");
|
||||
Files.writeString(shot, "fake png");
|
||||
ConfigLocations locations = locations();
|
||||
|
||||
PhotoSync core = started(locations);
|
||||
core.queue().enqueue(CapturedScreenshot.of(shot, CaptureOrigin.MANUAL), AlbumRef.library(), "test");
|
||||
assertTrue(Files.exists(instance.resolve("uploads.json")));
|
||||
|
||||
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
|
||||
|
||||
// The queue references instance-local screenshot paths, so it stays put.
|
||||
assertTrue(Files.exists(instance.resolve("uploads.json")));
|
||||
assertFalse(Files.exists(user.resolve("uploads.json")));
|
||||
core.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user