Author SHA1 Message Date
iceBear67 3afbdb2af2 Add: support for user-global configuration. 2026-08-08 14:09:43 +08:00
24 changed files with 499 additions and 14 deletions
+7 -1
View File
@@ -169,11 +169,17 @@ whatever is nearly done and then quits regardless.
## Files it writes ## 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. - `photosync.json` — settings, including your API key. Back it up accordingly.
- `uploads.json` — the upload queue, so it survives a crash or a quit. - `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 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 never moves them, and only deletes one if you asked it to and the upload was
confirmed. confirmed.
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose; import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle; import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader; 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. * loader's answers have not moved once in the supported range.
*/ */
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance() private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft") .getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString()) .map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -58,6 +60,11 @@ public final class GameAdapter implements GameContext {
return configDirectory; return configDirectory;
} }
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override @Override
public void submit(Runnable task) { public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance(); Minecraft game = Minecraft.getInstance();
@@ -0,0 +1,25 @@
package dev.photosync.platform;
import java.nio.file.Path;
/**
* The OS-user-level directory PhotoSync offers for its settings file.
*
* <p>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");
}
}
@@ -2,6 +2,7 @@ package dev.photosync.client;
import dev.photosync.core.PhotoSync; import dev.photosync.core.PhotoSync;
import dev.photosync.core.capture.CapturedScreenshot; import dev.photosync.core.capture.CapturedScreenshot;
import dev.photosync.core.config.ConfigLocations;
import dev.photosync.core.config.NotificationKind; import dev.photosync.core.config.NotificationKind;
import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.upload.UploadEvent; import dev.photosync.core.upload.UploadEvent;
@@ -52,7 +53,9 @@ public final class PhotoSyncClient implements AutoCloseable {
private final AutoCapture autoCapture; private final AutoCapture autoCapture;
public PhotoSyncClient(ClientBridge bridge) { 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. */ /** Takes its collaborators explicitly so a test can drive it without a game. */
@@ -98,6 +98,12 @@
"photosync.settings.cache": "Thumbnails kept in memory", "photosync.settings.cache": "Thumbnails kept in memory",
"photosync.settings.video_badge": "Mark videos in the grid", "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.save": "Save",
"photosync.settings.revert": "Revert", "photosync.settings.revert": "Revert",
"photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.", "photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.",
@@ -98,6 +98,12 @@
"photosync.settings.cache": "内存中保留的缩略图数量", "photosync.settings.cache": "内存中保留的缩略图数量",
"photosync.settings.video_badge": "在网格中标记视频", "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.save": "保存",
"photosync.settings.revert": "还原", "photosync.settings.revert": "还原",
"photosync.settings.dirty": "有未保存的更改——关闭此界面时保存。", "photosync.settings.dirty": "有未保存的更改——关闭此界面时保存。",
@@ -1,5 +1,7 @@
package dev.photosync.core; 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.ConfigService;
import dev.photosync.core.config.ConfigStore; import dev.photosync.core.config.ConfigStore;
import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.config.PhotoSyncConfig;
@@ -14,7 +16,10 @@ import dev.photosync.core.upload.UploadQueue;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
@@ -22,9 +27,9 @@ import java.util.List;
* Everything the mod does that is not Minecraft, assembled. * Everything the mod does that is not Minecraft, assembled.
* *
* <p>This is the seam the platform code sees: a Fabric entrypoint builds one of * <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 * these with a {@link ConfigLocations} -- where the settings file may live --
* off it. Nothing below this package knows what a {@code Screen} is, and nothing * and then only ever talks to the services hanging off it. Nothing below this
* here reaches back up. * 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 * <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 * 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 CONFIG_FILE = "photosync.json";
private static final String QUEUE_FILE = "uploads.json"; private static final String QUEUE_FILE = "uploads.json";
@Getter
private final ConfigLocations locations;
private volatile ConfigLocation activeLocation;
@Getter @Getter
private final ProviderCatalog catalog; private final ProviderCatalog catalog;
@Getter @Getter
@@ -52,19 +61,33 @@ public final class PhotoSync implements AutoCloseable {
@Getter @Getter
private final ThumbnailLoader thumbnails; private final ThumbnailLoader thumbnails;
/** The standard set of backends. */ /** The standard set of backends, stored in the given directory. */
public PhotoSync(Path 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. */ /** Takes the backend list explicitly so tests can run against a fake one. */
public PhotoSync(Path directory, List<ProviderFactory> providers) { 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.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); this.session = new ProviderSession(catalog);
// Reads and repairs the on-disk queue, so anything interrupted by the // The queue stays in the instance directory even when the settings file
// last quit is already pending again by the time uploads start. // moves: its entries reference screenshot paths that are local to the
this.queue = new UploadQueue(directory.resolve(QUEUE_FILE)); // 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.uploads = new UploadCoordinator(queue, session, () -> config.current().upload());
this.browser = new TimelineBrowser(session); this.browser = new TimelineBrowser(session);
this.thumbnails = new ThumbnailLoader(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. */ /** Connects to the configured backend and starts draining the queue. */
public PhotoSync start() { public PhotoSync start() {
config.onChange(this::applyConnection); config.onChange(this::applyConnection);
config.onChange(this::applyLocation);
applyConnection(config.current()); applyConnection(config.current());
reconcileLocation();
uploads.start(); uploads.start();
int resumed = queue.activeCount(); int resumed = queue.activeCount();
if (resumed > 0) { if (resumed > 0) {
@@ -103,6 +128,61 @@ public final class PhotoSync implements AutoCloseable {
uploads.wake(); 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 @Override
public void close() { public void close() {
thumbnails.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 lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -61,4 +62,9 @@ public final class ConfigService {
log.error("Could not write {}", store.path(), e); 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 @Slf4j
public final class ConfigStore { public final class ConfigStore {
private final JsonFile file; private JsonFile file;
private final ProviderId fallbackProvider; private final ProviderId fallbackProvider;
public ConfigStore(Path path, ProviderId fallbackProvider) { public ConfigStore(Path path, ProviderId fallbackProvider) {
@@ -37,6 +37,11 @@ public final class ConfigStore {
return file.path(); 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. */ /** Never throws: a broken config falls back to defaults rather than blocking start-up. */
public PhotoSyncConfig load() { public PhotoSyncConfig load() {
PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider); PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider);
@@ -21,7 +21,8 @@ public record PhotoSyncConfig(
UploadSettings upload, UploadSettings upload,
AutoCaptureSettings autoCapture, AutoCaptureSettings autoCapture,
NotificationSettings notifications, NotificationSettings notifications,
BrowserSettings browser) { BrowserSettings browser,
ConfigLocation location) {
public static PhotoSyncConfig defaults(ProviderId provider) { public static PhotoSyncConfig defaults(ProviderId provider) {
return PhotoSyncConfig.builder() return PhotoSyncConfig.builder()
@@ -32,6 +33,7 @@ public record PhotoSyncConfig(
.autoCapture(AutoCaptureSettings.defaults()) .autoCapture(AutoCaptureSettings.defaults())
.notifications(NotificationSettings.defaults()) .notifications(NotificationSettings.defaults())
.browser(BrowserSettings.defaults()) .browser(BrowserSettings.defaults())
.location(ConfigLocation.INSTANCE)
.build(); .build();
} }
@@ -49,6 +51,7 @@ public record PhotoSyncConfig(
.autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized()) .autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized())
.notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized()) .notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized())
.browser((browser == null ? BrowserSettings.defaults() : browser).normalized()) .browser((browser == null ? BrowserSettings.defaults() : browser).normalized())
.location(location == null ? ConfigLocation.INSTANCE : location)
.build(); .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();
}
}
@@ -26,9 +26,23 @@ public interface GameContext {
*/ */
Optional<CameraPose> cameraPose(); Optional<CameraPose> 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(); 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. * Runs a task on the render thread, or immediately if already on it.
* *
@@ -2,6 +2,7 @@ package dev.photosync.ui.screen;
import dev.photosync.core.config.AutoCaptureSettings; import dev.photosync.core.config.AutoCaptureSettings;
import dev.photosync.core.config.BrowserSettings; import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.config.ConfigLocation;
import dev.photosync.core.config.NotificationSettings; import dev.photosync.core.config.NotificationSettings;
import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.config.UploadSettings; import dev.photosync.core.config.UploadSettings;
@@ -122,6 +123,7 @@ public final class SettingsScreen extends PhotoSyncScreen {
rows.clear(); rows.clear();
measuredLineHeight = -1; measuredLineHeight = -1;
buildStorage();
buildConnection(); buildConnection();
buildUpload(); buildUpload();
buildAutoCapture(); buildAutoCapture();
@@ -129,6 +131,24 @@ public final class SettingsScreen extends PhotoSyncScreen {
buildBrowser(); 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() { private void buildConnection() {
heading("photosync.settings.section.connection"); heading("photosync.settings.section.connection");