Compare commits

...
10 Commits
Author SHA1 Message Date
iceBear67 b0a23544e3 bump version 2026-08-08 14:15:17 +08:00
iceBear67 4aa578121d Add: support for user-global configuration. 2026-08-08 14:14:11 +08:00
iceBear67 2f484157e6 Merge pull request #1 from iceBear67/imgbot
[ImgBot] Optimize images
2026-08-08 14:10:14 +08:00
iceBear67 7c3262ee2e fix workflow failure 2026-08-08 13:21:20 +08:00
iceBear67 addf04b3b4 bump version 2026-08-08 13:14:10 +08:00
iceBear67 644ec33b91 setup workflow 2026-08-08 13:13:27 +08:00
iceBear67 2c69d10361 upload client name and server address as description 2026-08-08 13:05:57 +08:00
iceBear67 c3a9290477 add zh_cn translation 2026-08-08 12:34:25 +08:00
ImgBotApp 941ad2c499 [ImgBot] Optimize images
*Total -- 1,454.01kb -> 1,250.03kb (14.03%)

/images/settings.png -- 198.01kb -> 154.76kb (21.84%)
/images/photo_preview.png -- 1,256.00kb -> 1,095.27kb (12.8%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-08-07 20:06:39 +00:00
iceBear67 b7ab385550 add disclaimer and screenshots 2026-08-08 03:53:33 +08:00
40 changed files with 1051 additions and 47 deletions
+82
View File
@@ -0,0 +1,82 @@
name: Release
# Builds the nine platform jars and publishes them as a GitHub release.
#
# Two ways in:
# - push a tag matching v* (e.g. v1.0.0) -- the release is created at that
# tag, and the tag is checked against mod_version in gradle.properties so
# the jars and the release cannot disagree about what version they are;
# - run it by hand (workflow_dispatch) -- the release is created at the
# dispatched commit under the tag derived from mod_version, creating the
# tag if it does not exist yet.
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Loom checks the Minecraft jar against the Gradle JVM at configuration
# time, and 26.2's jar requires Java 25 -- so Gradle itself has to run on
# 25. The JDK 21 toolchain the other buckets and the shared modules
# compile with is provisioned by Gradle through the foojay resolver in
# settings.gradle, the same mechanism a local build uses.
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build all platforms
run: ./gradlew test buildAllPlatforms
- name: Resolve version
id: version
run: |
VERSION="$(sed -n 's/^mod_version=//p' gradle.properties)"
echo "mod_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event_name }}" == "push" && "v$VERSION" != "${{ github.ref_name }}" ]]; then
echo "::error::Tag ${{ github.ref_name }} does not match mod_version=$VERSION in gradle.properties (expected v$VERSION)"
exit 1
fi
- name: Collect jars
run: |
mkdir -p dist
for jar in platform/*/build/libs/*.jar; do
case "$jar" in
*-sources.jar) ;;
*) cp "$jar" dist/ ;;
esac
done
# platform/common is a source root, not a bucket -- count the real ones.
buckets="$(ls -d platform/*/ | grep -vc 'platform/common/')"
jars="$(ls dist | wc -l)"
test "$jars" -eq "$buckets"
ls -la dist
- name: Publish release
uses: softprops/action-gh-release@v2
with:
# On a tag push the pushed tag is used as-is; on a manual run the
# tag comes from mod_version and is created at the built commit.
tag_name: ${{ github.event_name == 'workflow_dispatch' && steps.version.outputs.tag || '' }}
target_commitish: ${{ github.event_name == 'workflow_dispatch' && github.sha || '' }}
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
-14
View File
@@ -1,14 +0,0 @@
写一个照片同步 Mod, 要求如下:
1. 支持多版本(从 1.20 开始支持到现在最新的版本,目前是 26.2)和 fabric, 并且提供完善的文档描述兼容新版本的常规流程,注意事项。要有一个合理的模块层级把有必要跟mc代码纠缠在一起的部分隔离开,而无关的部分抽出来。允许兼容层的出现
2. 支持把截图上传到 immich,并且还可以设置是哪个 album。记得留一个抽象,未来可能会加入新的 provider.
3. 要有 GUI 用于管理这些截图,并且 gui 也要覆盖模组的设置。模组的 UI/UX 和交互应该设计的易于使用,用户配置好后只需要管截图即可。GUI 还要能按照时间线排列浏览(模拟网页端虚拟列表懒加载)album 上其他时间的图片,单位是天。不要支持视频功能,如果视频有 preview 可用就用 preview 图片然后叠一层视频标记
4. 要处理好用户退出游戏时截图仍在上传的情况
5. 开发时,使用编译 classpath 里已有的 Gson,不要自己编写 Json 解析器,也不要自己编写其他已有库中的内容。然而,要注意控制模组的体积,无必要不要 shade 或者 jij 依赖。如果必须这么做,配置 proguard 做 minify.
6. 你不是在做 MVP, 你是在落地一个具体的产品,因此代码风格上不要到处随便飞 static 和常量以及各种 util。用可用的 Java 的特性合理的,恰当的组织你的代码,使他们符合面向对象和略微函数式一些的 Modern Java Paradiam,但注意不要过度抽象. 允许使用 Lombok 改进代码可读性。
7. 要有一个可以配置的,默认关闭的自动截图功能,截图出来的图片需要有一个可配置的文件名后缀(如\_auto)
8. 自动截图/截图上传成功后可以在画面左下角提醒玩家(短短一行字停留一会即可,不要太影响体验)
你的 gui 可能需要使用 mc screen 的那一套 gui, 最坏的情况你要为了所有版本都重新编写 screen 渲染逻辑。如果已经到了这个地步,那么请考虑 imgui.
你可以先尝试做一些实验来研究可行性,然后问我有什么需要补充的信息。
+36 -12
View File
@@ -1,7 +1,11 @@
# PhotoSync
> [!NOTIFY]
> This mod is mostly generated by Claude. Bug feedbacks are welcomed and they will get
> fixed and replied by human.
A Fabric client mod that sends your Minecraft screenshots to [Immich](https://immich.app)
and lets you browse the album back inside the game.
and lets you browse the album back inside the game. This mod also supports periodic screenshooting.
Take a screenshot with F2 and it uploads in the background. Press F6 to see what
is uploading, scroll through the album's timeline, or change how any of it
@@ -9,7 +13,11 @@ behaves. Nothing about the vanilla screenshot flow changes — F2 still saves a
to `screenshots/` and still works with the mod misconfigured or offline.
Supports **Minecraft 1.20 through 26.2**, client side only. Requires
[Fabric API](https://modrinth.com/mod/fabric-api).
[Fabric API](https://modrinth.com/mod/fabric-api). Sinytra connector should work.
| Photo Browser | Settings |
| - | - |
| ![preview](./images/photo_preview.png) | ![settings](./images/settings.png) |
---
@@ -21,15 +29,15 @@ Supports **Minecraft 1.20 through 26.2**, client side only. Requires
| Your Minecraft version | Jar |
| --- | --- |
| 1.20 1.20.1 | `photosync-1.20.1-1.0.0.jar` |
| 1.20.2 1.20.4 | `photosync-1.20.4-1.0.0.jar` |
| 1.20.5 1.20.6 | `photosync-1.20.6-1.0.0.jar` |
| 1.21 1.21.1 | `photosync-1.21.1-1.0.0.jar` |
| 1.21.2 1.21.4 | `photosync-1.21.4-1.0.0.jar` |
| 1.21.5 | `photosync-1.21.5-1.0.0.jar` |
| 1.21.6 1.21.10 | `photosync-1.21.8-1.0.0.jar` |
| 1.21.11 1.21.x | `photosync-1.21.11-1.0.0.jar` |
| 26.1 26.2 | `photosync-26.2-1.0.0.jar` |
| 1.20 1.20.1 | `photosync-1.20.1-x.y.z.jar` |
| 1.20.2 1.20.4 | `photosync-1.20.4-x.y.z.jar` |
| 1.20.5 1.20.6 | `photosync-1.20.6-x.y.z.jar` |
| 1.21 1.21.1 | `photosync-1.21.1-x.y.z.jar` |
| 1.21.2 1.21.4 | `photosync-1.21.4-x.y.z.jar` |
| 1.21.5 | `photosync-1.21.5-x.y.z.jar` |
| 1.21.6 1.21.10 | `photosync-1.21.8-x.y.z.jar` |
| 1.21.11 1.21.x | `photosync-1.21.11-x.y.z.jar` |
| 26.1 26.2 | `photosync-26.2-x.y.z.jar` |
Each jar declares its own supported range, so Fabric will refuse to load the
wrong one rather than crashing later.
@@ -57,6 +65,11 @@ Then pick an **Album**. "Whole library" uploads without adding to any album; the
picker also creates one if you type a name and press Create. The chosen album is
both where uploads go and what the Browse tab shows.
Every upload carries a one-line description: the window title (which a modpack
replaces with its own name) and the server you were on, or "Singleplayer". It is
captured the moment the screenshot is taken, so an album mixed from several
clients or worlds stays tellable apart.
Settings save when you close the screen. **Revert** undoes everything since you
opened it.
@@ -156,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.
@@ -202,6 +221,11 @@ version in a dev client:
./gradlew :platform:1.21.11:runClient
```
Releases are built and published by `.github/workflows/release.yml`: push a
`v<version>` tag (which must match `mod_version` in `gradle.properties`), or run
the workflow by hand and it builds the current commit and releases it under the
`mod_version` tag.
The source tree is one shared implementation plus nine thin per-version
adapters. If you are adding a Minecraft version or wondering why the layout is
the way it is, [`docs/PORTING.md`](docs/PORTING.md) is the document for that: it
+3 -2
View File
@@ -107,8 +107,7 @@ while `fill` and `enableScissor` did not change once in six years. Section 3 is
the evidence: nine versions, and the drawing primitives collapse to three
adapter shapes.
This is also why the mod does **not** need imgui, which `PROMPT.md` raised as
the fallback if every version needed its own render logic. Every version does
This is also why the mod does **not** need imgui. Every version does
need its own adapter, but each adapter is a few dozen lines of delegation, not a
GUI toolkit.
@@ -347,6 +346,8 @@ deletes the placeholder.
| set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** |
| render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** |
| `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ |
| current server | `Minecraft.getCurrentServer()``ServerData.ip` | ✓ | ✓ | ✓ | ✓ |
| window title | `Window.setTitle(String)`, recorded by `TitleMixin` | ✓ | ✓ | ✓ | ✓ |
| `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ |
| camera entity | `Minecraft.getCameraEntity()``world.entity.Entity` | ✓ | ✓ | ✓ |
| camera pose | `Entity.getX/getY/getZ/getYRot/getXRot` | ✓ | ✓ | ✓ |
+1 -1
View File
@@ -7,7 +7,7 @@ org.gradle.configuration-cache=false
mod_id=photosync
mod_name=PhotoSync
mod_version=1.0.0
mod_version=0.2.0
mod_group=dev.photosync
mod_description=Sync your Minecraft screenshots to Immich, and browse your albums in game.
mod_license=MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.util.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.Util;
@@ -22,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())
@@ -56,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();
@@ -88,6 +96,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -2,6 +2,8 @@ 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;
import net.minecraft.util.Util;
@@ -22,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 +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();
@@ -89,6 +97,18 @@ public final class GameAdapter implements GameContext {
return minecraftVersion;
}
@Override
public String clientName() {
String title = WindowTitle.last();
return title == null || title.isBlank() ? "Minecraft" : title;
}
@Override
public Optional<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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");
}
}
@@ -0,0 +1,29 @@
package dev.photosync.platform;
/**
* The most recent window title, for the upload description's client name.
*
* <p>Static for the same reason as {@code ScreenshotBus} and {@code QuitGuard}:
* the thing that notices the title changing is {@code TitleMixin}, woven into a
* Minecraft class that nobody constructs, so it has nowhere to be handed a
* collaborator. A static rendezvous point is the only option, and the mixin
* writes here and the {@code GameAdapter} reads from here, so the storage has
* exactly one owner.
*/
public final class WindowTitle {
private static volatile String last = "";
private WindowTitle() {
}
/** Called by {@code TitleMixin} whenever the window is given a title. */
public static void record(String title) {
last = title;
}
/** The current window title, or empty before the first {@code setTitle}. */
public static String last() {
return last;
}
}
@@ -0,0 +1,30 @@
package dev.photosync.platform.mixin;
import com.mojang.blaze3d.platform.Window;
import dev.photosync.platform.WindowTitle;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Hooks the window title, for the upload description's client name.
*
* <p>There is no way to read the title back from GLFW before lwjgl 3.4 (the
* buckets below 26.x ship 3.3.x, which has no {@code glfwGetWindowTitle}), so
* the title is captured as every caller passes it through {@code setTitle} --
* the one public way to change it, and the one every title mod uses.
*
* <p>Like {@code QuitMixin}, the hook only records into a static holder and
* holds no state of its own. {@code setTitle(String)} has not moved in the
* supported range; the day a bucket disagrees, this class moves down into all
* nine copies like any other common class.
*/
@Mixin(Window.class)
public class TitleMixin {
@Inject(method = "setTitle", at = @At("HEAD"))
private void photosync$capture(String title, CallbackInfo callback) {
WindowTitle.record(title);
}
}
@@ -6,7 +6,8 @@
"client": [
"CaptureMixin",
"HudMixin",
"QuitMixin"
"QuitMixin",
"TitleMixin"
],
"injectors": {
"defaultRequire": 1
@@ -2,10 +2,12 @@ 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;
import dev.photosync.mcapi.ClientBridge;
import dev.photosync.mcapi.GameContext;
import dev.photosync.mcapi.capture.ScreenshotBus;
import dev.photosync.mcapi.lifecycle.QuitGuard;
import dev.photosync.mcapi.render.RenderBridge;
@@ -51,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. */
@@ -114,7 +118,29 @@ public final class PhotoSyncClient implements AutoCloseable {
log.debug("Not queuing {}: no provider configured", shot.fileName());
return;
}
core.queue().enqueue(shot, config.album());
core.queue().enqueue(shot, config.album(), captureDescription());
}
/**
* One line for the upload's description: the window title -- which a
* modpack replaces with its own name -- and the server being played on, or
* the word for single player.
*
* <p>Snapshot at capture time, because the queue outlives the session: a
* screenshot retried tomorrow should describe the moment it was taken, not
* whatever server the player is on when the retry fires.
*/
private String captureDescription() {
try {
GameContext game = bridge.game();
String where = game.serverAddress()
.orElseGet(() -> chrome().translate("photosync.description.singleplayer"));
return game.clientName() + " · " + where;
} catch (RuntimeException e) {
// A description that cannot be captured must not cost the upload.
log.warn("Could not capture the screenshot context", e);
return "";
}
}
private void uploadChanged(UploadEvent event) {
@@ -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.",
@@ -114,6 +120,8 @@
"photosync.notify.uploaded": "Uploaded %s",
"photosync.notify.failed": "Upload failed: %s",
"photosync.description.singleplayer": "Singleplayer",
"photosync.provider.immich.name": "Immich",
"photosync.provider.immich.endpoint": "Server URL",
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
@@ -0,0 +1,130 @@
{
"key.categories.photosync": "PhotoSync",
"key.category.photosync.main": "PhotoSync",
"key.photosync.open": "打开 PhotoSync",
"photosync.tab.queue": "上传",
"photosync.tab.browse": "浏览",
"photosync.tab.settings": "设置",
"photosync.state.pending": "排队中",
"photosync.state.uploading": "上传中",
"photosync.state.retrying": "重试中",
"photosync.state.completed": "已完成",
"photosync.state.failed": "失败",
"photosync.state.cancelled": "已取消",
"photosync.queue.retry_all": "重试失败的",
"photosync.queue.clear_finished": "清除已完成",
"photosync.queue.retry": "重试",
"photosync.queue.cancel": "取消",
"photosync.queue.reveal": "在文件夹中显示",
"photosync.queue.forget": "移除",
"photosync.queue.empty": "还没有上传",
"photosync.queue.empty.hint": "你拍摄的截图会显示在这里。",
"photosync.queue.no_selection": "选择一个截图以在此查看。",
"photosync.queue.preview_failed": "预览不可用",
"photosync.queue.preview_loading": "正在加载预览...",
"photosync.queue.idle": "没有可上传的内容",
"photosync.queue.status": "%s 个进行中,%s 个失败",
"photosync.browse.refresh": "刷新",
"photosync.browse.showing_library": "显示全部",
"photosync.browse.showing_album": "显示相册",
"photosync.browse.not_configured": "未连接",
"photosync.browse.not_configured.hint": "请在设置中添加你的服务器和 API 密钥。",
"photosync.browse.loading": "正在加载时间线...",
"photosync.browse.failed": "无法加载时间线",
"photosync.browse.empty": "这里还没有内容",
"photosync.browse.page_failed": "无法加载这些照片",
"photosync.browse.opening": "正在加载完整照片...",
"photosync.browse.open_failed": "无法加载这张照片",
"photosync.browse.open_failed.hint": "关闭后重新打开以重试。",
"photosync.browse.close_hint": "按 Esc 关闭",
"photosync.browse.status": "%s 天内的 %s 张照片",
"photosync.album.library": "整个图库",
"photosync.album.back": "返回",
"photosync.album.refresh": "刷新",
"photosync.album.create": "创建",
"photosync.album.new_hint": "新相册名称",
"photosync.album.loading": "正在加载相册...",
"photosync.album.failed": "无法加载相册",
"photosync.album.not_configured": "请先添加你的服务器和 API 密钥。",
"photosync.album.status": "正在上传到:%s",
"photosync.settings.section.connection": "连接",
"photosync.settings.section.upload": "上传",
"photosync.settings.section.auto_capture": "自动截图",
"photosync.settings.section.notifications": "通知",
"photosync.settings.section.browser": "浏览",
"photosync.settings.provider": "服务",
"photosync.settings.connection_state": "连接状态",
"photosync.settings.test": "测试",
"photosync.settings.test.running": "正在测试...",
"photosync.settings.test.incomplete": "请先填写两个字段。",
"photosync.settings.test.ok": "已连接为 %s(服务器 %s",
"photosync.settings.test.failed": "失败:%s",
"photosync.settings.album": "相册",
"photosync.settings.upload_on_capture": "自动上传截图",
"photosync.settings.upload_on_capture.detail": "你拍摄的每张截图都会进入上传队列。",
"photosync.settings.concurrency": "同时上传数量",
"photosync.settings.attempts": "放弃前的尝试次数",
"photosync.settings.backoff": "尝试间隔",
"photosync.settings.wait_on_quit": "上传中途退出前询问",
"photosync.settings.wait_on_quit.detail": "显示进度并让上传完成。未完成的上传无论如何都会在下次继续。",
"photosync.settings.delete_local": "上传后删除本地文件",
"photosync.settings.delete_local.detail": "仅在服务器确认后才删除。",
"photosync.settings.auto_capture": "按定时器自动截图",
"photosync.settings.auto_capture.detail": "默认关闭。",
"photosync.settings.interval": "每",
"photosync.settings.suffix": "名称后缀",
"photosync.settings.only_in_world": "仅在游戏世界中时",
"photosync.settings.skip_when_screen_open": "打开界面时跳过",
"photosync.settings.skip_when_still": "未移动时跳过",
"photosync.settings.skip_when_still.detail": "如果画面自上次自动截图以来没有变化,则跳过一次。",
"photosync.settings.notify": "在角落显示消息",
"photosync.settings.notify.detail": "在左下角显示一行,持续片刻。",
"photosync.settings.notify_capture": "拍摄截图时",
"photosync.settings.notify_uploaded": "上传完成时",
"photosync.settings.notify_failed": "上传失败时",
"photosync.settings.linger": "消息持续时间",
"photosync.settings.tile_size": "缩略图大小",
"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": "有未保存的更改——关闭此界面时保存。",
"photosync.settings.clean": "所有更改已保存。",
"photosync.quit.title": "上传仍在进行",
"photosync.quit.remaining": "还剩 %s 个待上传",
"photosync.quit.finishing": "正在完成...",
"photosync.quit.hint": "未完成的内容会在你下次游玩时继续。",
"photosync.quit.keep_playing": "继续游戏",
"photosync.quit.anyway": "仍然退出",
"photosync.notify.captured": "截图已保存:%s",
"photosync.notify.uploaded": "已上传 %s",
"photosync.notify.failed": "上传失败:%s",
"photosync.description.singleplayer": "单人",
"photosync.provider.immich.name": "Immich",
"photosync.provider.immich.endpoint": "服务器地址",
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
"photosync.provider.immich.secret": "API 密钥",
"photosync.provider.immich.secret.hint": "账户设置 -> API 密钥"
}
@@ -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();
}
@@ -14,10 +14,12 @@ public record UploadRequest(
String fileName,
Instant capturedAt,
Instant modifiedAt,
AlbumRef album) {
AlbumRef album,
String description) {
public UploadRequest {
album = album == null ? AlbumRef.library() : album;
modifiedAt = modifiedAt == null ? capturedAt : modifiedAt;
description = description == null ? "" : description;
}
}
@@ -106,6 +106,19 @@ final class ImmichApi {
return decode(send(request), type);
}
/**
* PATCHes JSON and discards the response body -- the only PATCH PhotoSync
* makes is the asset description update, whose reply is the whole asset we
* do not need.
*/
void patch(String path, Object body) throws ProviderException {
HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
.build();
send(request);
}
<T> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
throws ProviderException {
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
@@ -43,6 +43,10 @@ final class ImmichDtos {
record AssetMediaResponse(String id, String status) {
}
/** Body for {@code PATCH /assets/:id} -- the only field PhotoSync writes. */
record UpdateAsset(String description) {
}
record BulkIds(List<String> ids) {
}
@@ -161,6 +161,9 @@ public final class ImmichProvider implements PhotoProvider {
if (album.isPresent()) {
addToAlbum(album.get(), response.id());
}
if (!request.description().isBlank()) {
setDescription(response.id(), request.description());
}
return new UploadReceipt(response.id(), outcome);
}
@@ -327,6 +330,24 @@ public final class ImmichProvider implements PhotoProvider {
}
}
/**
* Writes the one-line description after the asset exists, even when the
* upload answered {@code duplicate} -- a retried upload whose first
* response was lost reaches the server twice, and the description must land
* on whichever attempt gets there.
*
* <p>Failures are warnings, not errors: the screenshot is the point, and the
* queue must not show a failed upload for an asset the server is holding.
*/
private void setDescription(String assetId, String description) {
try {
api.patch("/assets/" + assetId, new ImmichDtos.UpdateAsset(description));
} catch (ProviderException e) {
log.warn("Uploaded {} but Immich would not set its description: {}",
assetId, e.getMessage());
}
}
/** Album filter plus the flags that keep archived and trashed assets out of the library view. */
private static Map<String, String> scope(AlbumRef album) {
Map<String, String> query = new LinkedHashMap<>();
@@ -29,6 +29,7 @@ public record UploadJob(
Instant capturedAt,
CaptureOrigin origin,
String albumId,
String description,
UploadState state,
int attempts,
Instant notBefore,
@@ -48,7 +49,7 @@ public record UploadJob(
}
public UploadRequest toRequest() {
return new UploadRequest(path(), fileName, capturedAt, capturedAt, album());
return new UploadRequest(path(), fileName, capturedAt, capturedAt, album(), description);
}
/** True once the backoff has elapsed and a worker may pick this up. */
@@ -123,8 +123,12 @@ public final class UploadQueue {
* <p>The de-duplication matters on the path where a capture is enqueued and
* the player immediately quits and relaunches: the restored job and a fresh
* rescan would otherwise both try to upload the same file.
*
* @param description one line of context captured with the screenshot, e.g.
* client and server; persisted with the job so a retry
* after a restart describes the same moment
*/
public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album) {
public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album, String description) {
String absolute = shot.file().toAbsolutePath().toString();
UploadJob created;
synchronized (lock) {
@@ -143,6 +147,7 @@ public final class UploadQueue {
.capturedAt(shot.capturedAt())
.origin(shot.origin())
.albumId(album.id().orElse(""))
.description(description)
.state(UploadState.PENDING)
.attempts(0)
.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();
}
}
@@ -0,0 +1,46 @@
package dev.photosync.core.upload;
import dev.photosync.core.provider.UploadRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* The description is captured once, at the moment the screenshot is taken, and
* must survive the queue's persistence and retries unchanged -- it describes
* the moment the shot was taken, not the moment it was sent.
*/
class UploadJobTest {
private static final Instant TAKEN = Instant.parse("2026-08-08T12:00:00Z");
@Test
@DisplayName("the context captured at enqueue time travels with the upload")
void descriptionSurvivesToRequest() {
UploadJob job = UploadJob.builder()
.id("job-1")
.file("/tmp/shot.png")
.fileName("shot.png")
.sizeBytes(42)
.capturedAt(TAKEN)
.description("My Pack · play.example.com")
.state(UploadState.PENDING)
.attempts(0)
.build();
assertEquals("My Pack · play.example.com", job.toRequest().description());
}
@Test
@DisplayName("a job restored from an old queue file has no description to send")
void nullDescriptionBecomesEmpty() {
UploadRequest request = new UploadRequest(
Path.of("/tmp/shot.png"), "shot.png", TAKEN, TAKEN, null, null);
assertEquals("", request.description());
}
}
@@ -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.
*
@@ -50,6 +64,20 @@ public interface GameContext {
/** The running Minecraft version, for logs and the settings screen's footer. */
String minecraftVersion();
/**
* The window title, which modpacks replace with their own name. Recorded
* from {@code Window.setTitle} rather than reconstructed, because only the
* window knows what some other mod put there.
*/
String clientName();
/**
* The multiplayer server's address, or empty in single player and on the
* title screen. Both the value and its absence are meaningful to the upload
* description.
*/
Optional<String> serverAddress();
/**
* Shuts the game down, having already opened {@code QuitGuard}'s gate.
*
@@ -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");