diff --git a/desktop/scripts/installer-custom.nsh b/desktop/scripts/installer-custom.nsh index 6dd09fb..33e7240 100644 --- a/desktop/scripts/installer-custom.nsh +++ b/desktop/scripts/installer-custom.nsh @@ -14,6 +14,34 @@ Var WDA_InstallDirPage +!macro customInit + ; Safety: older versions created an `output` junction inside the install directory that points to the + ; per-user AppData `output` folder. Some uninstall/update flows may traverse that junction and delete + ; real user data. Remove it as early as possible during install/update. + Call WDA_RemoveLegacyOutputLink +!macroend + +!macro customInstall + ; Provide a safe, non-junction way for users to locate the real per-user output directory. + ; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall). + ; `open-output.cmd` uses %APPDATA% so it works for the current user. + FileOpen $0 "$INSTDIR\output-location.txt" w + FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n" + FileClose $0 + + FileOpen $1 "$INSTDIR\open-output.cmd" w + ; NSIS escaping: use $\" to output a literal quote character into the .cmd file. + FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n" + FileClose $1 +!macroend + +Function WDA_RemoveLegacyOutputLink + ; $INSTDIR is usually the full install directory. Be defensive and also try the nested path + ; in case the installer is running before electron-builder appends "\${APP_FILENAME}". + RMDir "$INSTDIR\output" + RMDir "$INSTDIR\${APP_FILENAME}\output" +FunctionEnd + !macro customPageAfterChangeDir ; Add a confirmation page after the directory picker so users clearly see ; the final install location (includes the app sub-folder). @@ -90,6 +118,10 @@ Var /GLOBAL WDA_DeleteUserData !macro customUnInit ; Default: keep user data (also applies to silent uninstall / update uninstall). StrCpy $WDA_DeleteUserData "0" + + ; Safety: if an older build created an `output` junction inside the install dir, remove it early so + ; directory cleanup can't traverse it and delete the real per-user output folder. + RMDir "$INSTDIR\output" !macroend !macro customUnWelcomePage diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 0ed4605..f4a2082 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -163,7 +163,11 @@ function getExeDir() { function ensureOutputLink() { // Users often expect an `output/` folder near the installed exe. We keep the real data - // in the per-user data dir, and (when possible) create a Windows junction next to the exe. + // in the per-user data dir. + // + // NOTE: We intentionally avoid creating a junction/symlink inside the install directory. + // Some uninstall/update flows may traverse reparse points and delete the target directory, + // causing data loss (the install dir is removed on every update/reinstall). if (!app.isPackaged) return; const exeDir = getExeDir(); @@ -171,26 +175,56 @@ function ensureOutputLink() { if (!exeDir || !dataDir) return; const target = path.join(dataDir, "output"); - const linkPath = path.join(exeDir, "output"); + const legacyLinkPath = path.join(exeDir, "output"); - // If the target doesn't exist yet, create it so the link points somewhere real. + // Ensure the real output dir exists. try { fs.mkdirSync(target, { recursive: true }); } catch {} - // If something already exists at linkPath, do not overwrite it. + // Best-effort: remove a legacy junction/symlink at `exeDir/output` so uninstallers can't + // accidentally traverse it and delete the real per-user output directory. try { - if (fs.existsSync(linkPath)) return; + const st = fs.lstatSync(legacyLinkPath); + if (st.isSymbolicLink()) { + try { + fs.unlinkSync(legacyLinkPath); + logMain(`[main] removed legacy output link: ${legacyLinkPath}`); + } catch (err) { + logMain(`[main] failed to remove legacy output link: ${err?.message || err}`); + } + } else if (st.isDirectory()) { + const entries = fs.readdirSync(legacyLinkPath); + if (Array.isArray(entries) && entries.length === 0) { + // Remove an empty real directory to reduce confusion (it will be recreated by the backend if needed). + fs.rmdirSync(legacyLinkPath); + } else { + // Do not overwrite non-empty directories to avoid data loss. + // Note: data stored here will be wiped on update/reinstall. + logMain( + `[main] output dir exists in install dir (not a link): ${legacyLinkPath}. real data dir output: ${target}` + ); + } + } else { + logMain(`[main] output path exists and is not a directory/link: ${legacyLinkPath}`); + } } catch { - return; + // Doesn't exist yet. } + // Best-effort: drop a helper file next to the exe so users can find the real data. + // This avoids the data-loss risks of using junctions/symlinks under the install directory. try { - fs.symlinkSync(target, linkPath, "junction"); - logMain(`[main] created output link: ${linkPath} -> ${target}`); - } catch (err) { - logMain(`[main] failed to create output link: ${err?.message || err}`); - } + const p = path.join(exeDir, "output-location.txt"); + const text = `WeChatDataAnalysis data directory\n\nOutput folder:\n${target}\n`; + fs.writeFileSync(p, text, { encoding: "utf8" }); + } catch {} + + try { + const p = path.join(exeDir, "open-output.cmd"); + const text = `@echo off\r\nexplorer \"${target}\"\r\n`; + fs.writeFileSync(p, text, { encoding: "utf8" }); + } catch {} } function getMainLogPath() { @@ -1254,6 +1288,30 @@ function registerWindowIpc() { } }); + ipcMain.handle("app:getOutputDir", () => { + const dir = resolveDataDir(); + if (!dir) return ""; + return path.join(dir, "output"); + }); + + ipcMain.handle("app:openOutputDir", async () => { + const dir = resolveDataDir(); + if (!dir) throw new Error("无法定位数据目录"); + const outDir = path.join(dir, "output"); + try { + fs.mkdirSync(outDir, { recursive: true }); + } catch {} + try { + const err = await shell.openPath(outDir); + if (err) throw new Error(err); + return { success: true, path: outDir }; + } catch (e) { + const message = e?.message || String(e); + logMain(`[main] openOutputDir failed: ${message}`); + throw new Error(message); + } + }); + ipcMain.handle("app:checkForUpdates", async () => { return await checkForUpdatesInternal(); }); @@ -1272,6 +1330,11 @@ function registerWindowIpc() { } try { + // Safety: remove legacy `output` junctions in the install dir before triggering the NSIS update/uninstall. + // Some uninstall flows may traverse reparse points and delete the real per-user output directory. + try { + ensureOutputLink(); + } catch {} autoUpdater.quitAndInstall(false, true); return { success: true }; } catch (err) { @@ -1312,7 +1375,7 @@ async function main() { registerWindowIpc(); registerDebugShortcuts(); - // Resolve/create the data dir early so we can log reliably and (optionally) place a link + // Resolve/create the data dir early so we can log reliably and place helper files // next to the installed exe for easier access. resolveDataDir(); ensureOutputLink(); diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index e83f477..938feb4 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -19,6 +19,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", { chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options), + // Data/output folder helpers + getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"), + openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"), + // Auto update getVersion: () => ipcRenderer.invoke("app:getVersion"), checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"), diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index 6bc7560..7d13c2d 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -142,6 +142,24 @@
{{ desktopBackendPortError }}
+ +
+
+
output 目录
+
{{ desktopOutputDirText }}
+
+ +
+
+ {{ desktopOutputDirError }} +
@@ -288,6 +306,15 @@ const desktopBackendPortApplying = ref(false) const desktopBackendPortError = ref('') const desktopBackendPortDefault = ref(10392) +const desktopOutputDir = ref('') +const desktopOutputDirLoading = ref(false) +const desktopOutputDirError = ref('') +const desktopOutputDirText = computed(() => { + if (!isDesktopEnv.value) return '仅桌面端可用' + const v = String(desktopOutputDir.value || '').trim() + return v || '—' +}) + const switchTrackClass = (enabled, disabled = false) => { if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed' return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95' @@ -437,6 +464,36 @@ const refreshDesktopBackendPort = async () => { } } +const refreshDesktopOutputDir = async () => { + if (!process.client || typeof window === 'undefined') return + if (!window.wechatDesktop?.getOutputDir) return + desktopOutputDirLoading.value = true + desktopOutputDirError.value = '' + try { + const v = await window.wechatDesktop.getOutputDir() + desktopOutputDir.value = String(v || '').trim() + } catch (e) { + desktopOutputDirError.value = e?.message || '读取 output 目录失败' + } finally { + desktopOutputDirLoading.value = false + } +} + +const onDesktopOpenOutputDir = async () => { + if (!process.client || typeof window === 'undefined') return + if (!window.wechatDesktop?.openOutputDir) return + desktopOutputDirLoading.value = true + desktopOutputDirError.value = '' + try { + const res = await window.wechatDesktop.openOutputDir() + if (res?.path) desktopOutputDir.value = String(res.path || '').trim() + } catch (e) { + desktopOutputDirError.value = e?.message || '打开 output 目录失败' + } finally { + desktopOutputDirLoading.value = false + } +} + const applyDesktopBackendPort = async () => { if (!process.client || typeof window === 'undefined') return const raw = String(desktopBackendPortInput.value || '').trim() @@ -567,6 +624,7 @@ onMounted(async () => { void desktopUpdate.initListeners() await refreshDesktopAutoLaunch() await refreshDesktopCloseBehavior() + await refreshDesktopOutputDir() } await nextTick()