Compare commits
10
Commits
30d124fd8b
...
b0a23544e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a23544e3 | ||
|
|
4aa578121d | ||
|
|
2f484157e6 | ||
|
|
7c3262ee2e | ||
|
|
addf04b3b4 | ||
|
|
644ec33b91 | ||
|
|
2c69d10361 | ||
|
|
c3a9290477 | ||
|
|
941ad2c499 | ||
|
|
b7ab385550 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
你可以先尝试做一些实验来研究可行性,然后问我有什么需要补充的信息。
|
||||
@@ -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 |
|
||||
| - | - |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user