diff --git a/README.md b/README.md index 84a4319..8cb98f4 100644 --- a/README.md +++ b/README.md @@ -169,11 +169,17 @@ whatever is nearly done and then quits regardless. ## Files it writes -Everything lives in `config/photosync/` in your game directory: +By default everything lives in `config/photosync/` in your game directory: - `photosync.json` — settings, including your API key. Back it up accordingly. - `uploads.json` — the upload queue, so it survives a crash or a quit. +Turn on *Store settings outside the game folder* in Settings → Storage and +`photosync.json` moves to `~/.config/photosyncmod/` on Linux and macOS, or +`%APPDATA%\photosyncmod\` on Windows, shared across your Minecraft installs. +The game folder keeps `uploads.json` and a small `location.json` pointer that +says where the settings file went. + Screenshots themselves go where they always did, in `screenshots/`. PhotoSync never moves them, and only deletes one if you asked it to and the upload was confirmed. diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java index c07a9eb..d74564d 100644 --- a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bb62e6..88fd6f8 100644 --- a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java index cea5480..fc274a0 100644 --- a/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.UserDataDirectory; import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; @@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext { * loader's answers have not moved once in the supported range. */ private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final Path userDataDirectory = UserDataDirectory.resolve(); private final String minecraftVersion = FabricLoader.getInstance() .getModContainer("minecraft") .map(container -> container.getMetadata().getVersion().getFriendlyString()) @@ -58,6 +60,11 @@ public final class GameAdapter implements GameContext { return configDirectory; } + @Override + public Path userDataDirectory() { + return userDataDirectory; + } + @Override public void submit(Runnable task) { Minecraft game = Minecraft.getInstance(); diff --git a/platform/common/src/main/java/dev/photosync/platform/UserDataDirectory.java b/platform/common/src/main/java/dev/photosync/platform/UserDataDirectory.java new file mode 100644 index 0000000..61aea30 --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/UserDataDirectory.java @@ -0,0 +1,25 @@ +package dev.photosync.platform; + +import java.nio.file.Path; + +/** + * The OS-user-level directory PhotoSync offers for its settings file. + * + *

One rule, written once: Windows keeps per-user application data under + * {@code %APPDATA%}, everything else under {@code ~/.config} (the convention + * the Linux desktops and macOS share). Nine buckets compile this class, so a + * fix to the rule needs one edit instead of nine. + */ +public final class UserDataDirectory { + + private UserDataDirectory() { + } + + public static Path resolve() { + String appData = System.getenv("APPDATA"); + Path base = appData == null || appData.isBlank() + ? Path.of(System.getProperty("user.home"), ".config") + : Path.of(appData); + return base.resolve("photosyncmod"); + } +} diff --git a/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java index 2d58662..554b373 100644 --- a/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java +++ b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java @@ -2,6 +2,7 @@ package dev.photosync.client; import dev.photosync.core.PhotoSync; import dev.photosync.core.capture.CapturedScreenshot; +import dev.photosync.core.config.ConfigLocations; import dev.photosync.core.config.NotificationKind; import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.upload.UploadEvent; @@ -52,7 +53,9 @@ public final class PhotoSyncClient implements AutoCloseable { private final AutoCapture autoCapture; public PhotoSyncClient(ClientBridge bridge) { - this(bridge, new PhotoSync(bridge.game().configDirectory()), Theme.dark()); + this(bridge, new PhotoSync(new ConfigLocations( + bridge.game().configDirectory(), + bridge.game().userDataDirectory())), Theme.dark()); } /** Takes its collaborators explicitly so a test can drive it without a game. */ diff --git a/shared/client/src/main/resources/assets/photosync/lang/en_us.json b/shared/client/src/main/resources/assets/photosync/lang/en_us.json index b8ee6cf..408632b 100644 --- a/shared/client/src/main/resources/assets/photosync/lang/en_us.json +++ b/shared/client/src/main/resources/assets/photosync/lang/en_us.json @@ -98,6 +98,12 @@ "photosync.settings.cache": "Thumbnails kept in memory", "photosync.settings.video_badge": "Mark videos in the grid", + "photosync.settings.section.storage": "Storage", + "photosync.settings.user_config_location": "Store settings outside the game folder", + "photosync.settings.user_config_location.detail": "Settings move to %s and are shared across your Minecraft installs. The upload queue stays in the game folder.", + "photosync.settings.config_folder": "Config folder", + "photosync.settings.show_folder": "Show in folder", + "photosync.settings.save": "Save", "photosync.settings.revert": "Revert", "photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.", diff --git a/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json b/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json index 87d49e2..4ea8a5b 100644 --- a/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json +++ b/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json @@ -98,6 +98,12 @@ "photosync.settings.cache": "内存中保留的缩略图数量", "photosync.settings.video_badge": "在网格中标记视频", + "photosync.settings.section.storage": "存储", + "photosync.settings.user_config_location": "将设置存储在游戏文件夹之外", + "photosync.settings.user_config_location.detail": "设置将移动到 %s,并在你的多个 Minecraft 安装之间共享。上传队列仍保留在游戏文件夹中。", + "photosync.settings.config_folder": "配置文件夹", + "photosync.settings.show_folder": "在文件夹中显示", + "photosync.settings.save": "保存", "photosync.settings.revert": "还原", "photosync.settings.dirty": "有未保存的更改——关闭此界面时保存。", diff --git a/shared/core/src/main/java/dev/photosync/core/PhotoSync.java b/shared/core/src/main/java/dev/photosync/core/PhotoSync.java index 4c28174..c840ef2 100644 --- a/shared/core/src/main/java/dev/photosync/core/PhotoSync.java +++ b/shared/core/src/main/java/dev/photosync/core/PhotoSync.java @@ -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. * *

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. * *

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 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 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. + * + *

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(); diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigLocation.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigLocation.java new file mode 100644 index 0000000..f6be63a --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigLocation.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigLocations.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigLocations.java new file mode 100644 index 0000000..d202a22 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigLocations.java @@ -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. + * + *

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); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java index ade3c19..9832e29 100644 --- a/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java @@ -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); + } } diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java index 0d98715..9b5e053 100644 --- a/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java @@ -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); diff --git a/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java b/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java index e90fed0..d73be0a 100644 --- a/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java +++ b/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java @@ -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(); } diff --git a/shared/core/src/test/java/dev/photosync/core/config/ConfigLocationsTest.java b/shared/core/src/test/java/dev/photosync/core/config/ConfigLocationsTest.java new file mode 100644 index 0000000..a46c080 --- /dev/null +++ b/shared/core/src/test/java/dev/photosync/core/config/ConfigLocationsTest.java @@ -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)); + } +} diff --git a/shared/core/src/test/java/dev/photosync/core/config/PhotoSyncRelocationTest.java b/shared/core/src/test/java/dev/photosync/core/config/PhotoSyncRelocationTest.java new file mode 100644 index 0000000..1717bac --- /dev/null +++ b/shared/core/src/test/java/dev/photosync/core/config/PhotoSyncRelocationTest.java @@ -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(); + } +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java index 1a5bd37..77bc6c7 100644 --- a/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java @@ -26,9 +26,23 @@ public interface GameContext { */ Optional cameraPose(); - /** {@code .minecraft/config}, where PhotoSync keeps its settings and upload queue. */ + /** + * The game-instance config directory (the {@code config/photosync} folder + * inside the game directory), where PhotoSync historically keeps its + * settings and upload queue. The settings file can be moved to + * {@link #userDataDirectory()} from the settings screen; the upload queue + * always stays here. + */ Path configDirectory(); + /** + * The OS-user-level directory offered as the alternative home for the + * settings file: {@code ~/.config/photosyncmod} on Linux and macOS, + * {@code %APPDATA%\photosyncmod} on Windows. Only used once the player + * chooses it; {@link #configDirectory()} stays the default. + */ + Path userDataDirectory(); + /** * Runs a task on the render thread, or immediately if already on it. * diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java index 7cb9043..ed6cfe9 100644 --- a/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java @@ -2,6 +2,7 @@ package dev.photosync.ui.screen; import dev.photosync.core.config.AutoCaptureSettings; import dev.photosync.core.config.BrowserSettings; +import dev.photosync.core.config.ConfigLocation; import dev.photosync.core.config.NotificationSettings; import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.config.UploadSettings; @@ -122,6 +123,7 @@ public final class SettingsScreen extends PhotoSyncScreen { rows.clear(); measuredLineHeight = -1; + buildStorage(); buildConnection(); buildUpload(); buildAutoCapture(); @@ -129,6 +131,24 @@ public final class SettingsScreen extends PhotoSyncScreen { buildBrowser(); } + private void buildStorage() { + heading("photosync.settings.section.storage"); + Toggle outsideGame = new Toggle(chrome, chrome.translate("photosync.settings.user_config_location"), + () -> draft().location() == ConfigLocation.USER, + value -> edit(config -> config.toBuilder() + .location(value ? ConfigLocation.USER : ConfigLocation.INSTANCE) + .build())); + outsideGame.describedAs(chrome.translate("photosync.settings.user_config_location.detail", + ui.core().locations().directory(ConfigLocation.USER))); + rows.add(new Row("photosync.settings.user_config_location", content.add(outsideGame), true)); + control("photosync.settings.config_folder", + new Button(chrome, chrome.translate("photosync.settings.show_folder"), this::revealConfigFolder)); + } + + private void revealConfigFolder() { + ui.game().reveal(ui.core().dataDirectory()); + } + private void buildConnection() { heading("photosync.settings.section.connection");