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
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.
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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.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. */
@@ -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.",
@@ -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": "有未保存的更改——关闭此界面时保存。",
@@ -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();
}
}
@@ -26,9 +26,23 @@ public interface GameContext {
*/
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();
/**
* 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.
*
@@ -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");