feat: in-app updates (GitHub desktop, Play Android) + i18n fixes (#241)
* feat(desktop): share dialog on Linux/macOS via cloudreve://share deeplink The Explorer context menu is Windows-only; the share dialog itself is cross-platform but had no trigger elsewhere. cloudreve://share/<encoded local path> now routes through the same ManagerCommand::ShareLink pipeline, and desktop/linux/ ships a cloudreve-share helper plus Nautilus script and Dolphin service-menu glue so file managers can reach it. OAuth callback deeplinks are unaffected. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(desktop): don't drop share deep links at startup Queue cloudreve://share/ links arriving before APP_STATE init and drain after the sync service is up; also scan argv for a cloudreve:// URL so a cold start launched by the file manager still opens the share dialog. Nautilus script now reads NAUTILUS_SCRIPT_SELECTED_FILE_PATHS per line so paths with spaces survive. * feat(desktop): macOS share helper + file-manager install docs cloudreve-share for macOS (open cloudreve://share/...) plus Shortcuts Quick Action recipe for Finder. READMEs document Nautilus, Dolphin, and Finder installation. * feat: in-app updates (GitHub desktop, Play Android) + i18n fixes Desktop (GitHub builds): - tauri-plugin-updater wired to releases/latest/latest.json; minisign keypair generated, private key in TAURI_SIGNING_PRIVATE_KEY secrets - Startup check prompts once per version (config.prompted_update_version); About page gains a manual 'Check for updates' entry - New /update window: version + notes -> in-app download overlay with progress events -> install -> restart via app.restart() - Release workflow signs updater artifacts, uploads .sig/.app.tar.gz/ .AppImage.tar.gz, and a new updater-manifest job emits latest.json (desktop/scripts/gen_latest_json.py); tauri-action path signs natively - Desktop version bumped 0.2.0 -> 0.3.0 so released updates register Android (Play builds): - Play in-app update check on resume -> in-app dialog -> official IMMEDIATE update sheet (app-update-ktx); once per version via prefs - targetSdk/compileSdk 36 + AGP 8.9.3 (Play requires >= 36); bumped to versionCode 2 / 1.1.0 — uploaded and published on internal track i18n: - Desktop: translated share.* + settings.notificationSettings into all 10 non-English locales; added update.* everywhere; popup time strings (justNow/minutesAgo/...) localized via i18n.t in utils - Frontend: added 4 keys missing from en-US (raw-key rendering): modals.anonymous, modals.shareTargetsCount, common.save, file.selectUser (+ event.allEventTypes for zh-CN); fixed two mistyped keys (uplaoder->uploader, uploader:->application:uploader) --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3593/head
parent
bb215ffd8b
commit
8c6cbe09a0
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Tauri updater manifest (latest.json) from release .sig assets.
|
||||
|
||||
Usage: gen_latest_json.py <tag> <sig_dir> <output.json>
|
||||
|
||||
Scans <sig_dir> for `*.sig` files downloaded from the GitHub release, maps each
|
||||
to its updater platform, and emits the manifest the tauri-plugin-updater
|
||||
expects. Asset URLs point at the release's renamed `cloudreve-desktop-*`
|
||||
assets. The version is read from desktop/src-tauri/tauri.conf.json — desktop
|
||||
has its own semver, independent of the repo's server release tags.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
PLATFORM_MAP = [
|
||||
("_x64-setup.exe", "windows-x86_64"),
|
||||
("_arm64-setup.exe", "windows-aarch64"),
|
||||
("_aarch64.app.tar.gz", "darwin-aarch64"),
|
||||
("_x64.app.tar.gz", "darwin-x86_64"),
|
||||
("amd64.AppImage.tar.gz", "linux-x86_64"),
|
||||
("aarch64.AppImage.tar.gz", "linux-aarch64"),
|
||||
]
|
||||
|
||||
REPO = "Dvorinka/cloudreve"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag, sig_dir, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
|
||||
conf = json.load(open("desktop/src-tauri/tauri.conf.json"))
|
||||
version = conf["version"]
|
||||
|
||||
platforms = {}
|
||||
for fname in sorted(os.listdir(sig_dir)):
|
||||
if not fname.endswith(".sig"):
|
||||
continue
|
||||
# Release assets are renamed to `cloudreve-desktop-<basename>` at
|
||||
# upload time, so the downloaded .sig name already is the asset name.
|
||||
asset = fname[: -len(".sig")]
|
||||
for marker, platform in PLATFORM_MAP:
|
||||
if marker in asset:
|
||||
signature = open(os.path.join(sig_dir, fname)).read().strip()
|
||||
if not signature:
|
||||
print(f"warning: empty signature in {fname}, skipping")
|
||||
break
|
||||
platforms[platform] = {
|
||||
"signature": signature,
|
||||
"url": (
|
||||
f"https://github.com/{REPO}/releases/download/{tag}/"
|
||||
f"{asset}"
|
||||
),
|
||||
}
|
||||
break
|
||||
|
||||
if not platforms:
|
||||
print("error: no updater artifacts found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
manifest = {
|
||||
"version": version,
|
||||
"notes": f"Cloudreve Desktop {version}",
|
||||
"pub_date": "",
|
||||
"platforms": platforms,
|
||||
}
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print(f"latest.json: version={version}, platforms={sorted(platforms)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,227 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DenseFilledTextField } from "../common/StyledComponent";
|
||||
|
||||
type UpdateInfo = {
|
||||
version: string;
|
||||
current_version: string;
|
||||
notes?: string | null;
|
||||
date?: string | null;
|
||||
};
|
||||
|
||||
type Phase = "checking" | "available" | "downloading" | "ready" | "uptodate" | "error";
|
||||
|
||||
const RELEASES_URL = "https://github.com/Dvorinka/cloudreve/releases";
|
||||
|
||||
export default function Update() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("checking");
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const downloadedRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unProgress = listen<{ downloaded: number; total: number | null }>(
|
||||
"update-progress",
|
||||
(e) => {
|
||||
downloadedRef.current += e.payload.downloaded;
|
||||
if (e.payload.total) {
|
||||
setProgress(
|
||||
Math.min(100, (downloadedRef.current / e.payload.total) * 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
const unFinished = listen("update-finished", () => setPhase("ready"));
|
||||
|
||||
invoke<UpdateInfo | null>("check_update")
|
||||
.then((u) => {
|
||||
setInfo(u);
|
||||
setPhase(u ? "available" : "uptodate");
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(String(e));
|
||||
setPhase("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
unProgress.then((f) => f());
|
||||
unFinished.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startUpdate = async () => {
|
||||
setPhase("downloading");
|
||||
setError(null);
|
||||
downloadedRef.current = 0;
|
||||
setProgress(0);
|
||||
try {
|
||||
await invoke("install_update");
|
||||
// The ready phase is set by the update-finished listener; if the
|
||||
// event raced past us, still land on ready.
|
||||
setPhase((p) => (p === "downloading" ? "ready" : p));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setPhase("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-tauri-drag-region
|
||||
sx={{
|
||||
px: 2,
|
||||
pt: 1.5,
|
||||
pb: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600} noWrap>
|
||||
{t("update.title")}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => getCurrentWindow().close()}
|
||||
sx={{ WebkitAppRegion: "no-drag", appRegion: "no-drag" }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
px: 3,
|
||||
pb: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{phase === "checking" && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === "available" && info && (
|
||||
<>
|
||||
<Typography variant="body1" fontWeight={600}>
|
||||
{t("update.available", {
|
||||
version: info.version,
|
||||
current: info.current_version,
|
||||
})}
|
||||
</Typography>
|
||||
{info.notes && (
|
||||
<DenseFilledTextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
value={info.notes}
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "downloading" && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("update.downloading")}
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "ready" && (
|
||||
<Alert severity="success">{t("update.ready")}</Alert>
|
||||
)}
|
||||
|
||||
{phase === "uptodate" && (
|
||||
<Alert severity="info">{t("update.upToDate")}</Alert>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<Alert severity="error">{error ?? t("update.failed")}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{phase === "available" && (
|
||||
<>
|
||||
<Button onClick={() => getCurrentWindow().close()}>
|
||||
{t("update.later")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={startUpdate}>
|
||||
{t("update.updateNow")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{phase === "ready" && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => invoke("restart_app")}
|
||||
>
|
||||
{t("update.restart")}
|
||||
</Button>
|
||||
)}
|
||||
{(phase === "uptodate" || phase === "checking") && (
|
||||
<Button onClick={() => getCurrentWindow().close()}>
|
||||
{t("update.close")}
|
||||
</Button>
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<>
|
||||
<Button onClick={() => getCurrentWindow().close()}>
|
||||
{t("update.close")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => openUrl(RELEASES_URL)}>
|
||||
{t("update.downloadManually")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in new issue