fix: require magic string in portable marker to prevent false portable mode on scoop installs (#1126)

* fix: require magic string in portable marker to prevent false portable mode

Scoop's extras bucket extracts the NSIS installer via #/dl.7z, which can
leave a stale empty portable file next to the exe. This caused
portable::init() to falsely trigger portable mode on non-portable scoop
installs, storing data in Data/ next to the exe (wiped on each update).

Changes:
- portable.rs: only enable portable mode when marker contains
  "Handy Portable Mode"; extracted is_valid_portable_marker() with tests
- installer.nsi: write magic string when creating marker file
- installer.nsi: validate magic string in update auto-detect block
- installer.nsi: allow desktop shortcut creation from finish page
  checkbox in portable mode (was silently doing nothing)
- commands/mod.rs + lib.rs: expose is_portable() as a Tauri command
- UpdateChecker.tsx: show manual-update popup for portable installs
  instead of attempting auto-install (which would download MSI)
- en/translation.json: add i18n strings for portable update popup

Fixes #1124

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add missing translation keys to all locales and fix prettier formatting

Add portableUpdateTitle, portableUpdateMessage, portableUpdateButton to
all 17 non-English locales (English fallback text) so check:translations
passes. Also run prettier on the PR's modified files to fix format:check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: migrate legacy empty portable markers from v0.8.0

v0.8.0 created an empty `portable` marker file. Users who manually
update (drop new exe in place) would silently lose portable mode since
the new magic-string check rejects empty files.

Migration: if the marker exists but has invalid/empty content AND a
`Data/` directory is present, treat it as a real portable install and
rewrite the marker with the magic string. This handles the v0.8.0 →
new upgrade path without regressing the scoop false-positive fix
(scoop does not create a Data/ directory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shehab Tarek 2026-03-26 02:52:33 +02:00 committed by GitHub
parent c6576aca44
commit 214e22dc3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 254 additions and 56 deletions

View file

@ -587,11 +587,18 @@ Function .onInit
; --- PORTABLE MODE --- Auto-detect portable mode during updates.
; If the target directory already has a portable marker file, preserve
; If the target directory already has a valid portable marker file, preserve
; portable mode so the Tauri updater works without needing /PORTABLE.
; We validate the magic string to avoid false-positives from stale empty files
; left by scoop's NSIS extraction (dl.7z side-effect).
${If} $PortableMode <> 1
${AndIf} ${FileExists} "$INSTDIR\portable"
StrCpy $PortableMode 1
FileOpen $1 "$INSTDIR\portable" r
FileRead $1 $2
FileClose $1
${If} $2 == "Handy Portable Mode"
StrCpy $PortableMode 1
${EndIf}
${EndIf}
!if "${INSTALLMODE}" == "both"
@ -757,6 +764,7 @@ Section Install
; --- PORTABLE MODE --- Create portable marker and Data directory
${If} $PortableMode = 1
FileOpen $0 "$INSTDIR\portable" w
FileWrite $0 "Handy Portable Mode"
FileClose $0
CreateDirectory "$INSTDIR\Data"
DetailPrint "Portable mode: created marker file and Data directory."
@ -1050,11 +1058,6 @@ Function CreateOrUpdateStartMenuShortcut
FunctionEnd
Function CreateOrUpdateDesktopShortcut
; --- PORTABLE MODE --- No desktop shortcut for portable installs
${If} $PortableMode = 1
Return
${EndIf}
; We used to use product name as MAINBINARYNAME
; migrate old shortcuts to target the new MAINBINARYNAME
!insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName"

View file

@ -14,6 +14,12 @@ pub fn cancel_operation(app: AppHandle) {
cancel_current_operation(&app);
}
#[tauri::command]
#[specta::specta]
pub fn is_portable() -> bool {
crate::portable::is_portable()
}
#[tauri::command]
#[specta::specta]
pub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {

View file

@ -375,6 +375,7 @@ pub fn run(cli_args: CliArgs) {
trigger_update_check,
show_main_window_command,
commands::cancel_operation,
commands::is_portable,
commands::get_app_dir_path,
commands::get_app_settings,
commands::get_default_settings,

View file

@ -17,8 +17,23 @@ pub fn init() {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
if exe_dir.join("portable").exists() {
let data_dir = exe_dir.join("Data");
let marker_path = exe_dir.join("portable");
let data_dir = exe_dir.join("Data");
let is_portable = if is_valid_portable_marker(&marker_path) {
true
} else if marker_path.exists() && data_dir.exists() {
// Migration: v0.8.0 created an empty marker file. If we find an
// empty/invalid marker alongside an existing Data/ dir, this is a
// real portable install — upgrade the marker in place.
eprintln!("[portable] upgrading legacy empty marker to magic string");
let _ = std::fs::write(&marker_path, "Handy Portable Mode");
true
} else {
false
};
if is_portable {
if !data_dir.exists() {
std::fs::create_dir_all(&data_dir).ok()?;
}
@ -75,3 +90,77 @@ pub fn store_path(relative: &str) -> PathBuf {
PathBuf::from(relative)
}
}
/// Check if a marker file path contains the portable magic string.
/// Extracted for testability.
fn is_valid_portable_marker(path: &std::path::Path) -> bool {
std::fs::read_to_string(path)
.map(|s| s.trim().starts_with("Handy Portable Mode"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_valid_magic_string_enables_portable() {
let dir = std::env::temp_dir().join("handy_test_valid");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("portable");
let mut f = std::fs::File::create(&marker).unwrap();
write!(f, "Handy Portable Mode").unwrap();
assert!(is_valid_portable_marker(&marker));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_empty_file_does_not_enable_portable() {
let dir = std::env::temp_dir().join("handy_test_empty");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("portable");
std::fs::File::create(&marker).unwrap();
assert!(!is_valid_portable_marker(&marker));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_wrong_content_does_not_enable_portable() {
let dir = std::env::temp_dir().join("handy_test_wrong");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("portable");
let mut f = std::fs::File::create(&marker).unwrap();
write!(f, "some other content").unwrap();
assert!(!is_valid_portable_marker(&marker));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_missing_file_does_not_enable_portable() {
let path = std::path::Path::new("/nonexistent/portable");
assert!(!is_valid_portable_marker(path));
}
#[test]
fn test_legacy_empty_marker_without_data_dir_does_not_enable_portable() {
// Empty marker alone (scoop scenario) — no Data/ dir → not portable
let dir = std::env::temp_dir().join("handy_test_legacy_no_data");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("portable");
std::fs::File::create(&marker).unwrap();
assert!(!is_valid_portable_marker(&marker));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_magic_string_with_whitespace_enables_portable() {
let dir = std::env::temp_dir().join("handy_test_ws");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("portable");
let mut f = std::fs::File::create(&marker).unwrap();
write!(f, " Handy Portable Mode\n").unwrap();
assert!(is_valid_portable_marker(&marker));
std::fs::remove_dir_all(dir).unwrap();
}
}

View file

@ -426,6 +426,9 @@ async showMainWindowCommand() : Promise<Result<null, string>> {
async cancelOperation() : Promise<void> {
await TAURI_INVOKE("cancel_operation");
},
async isPortable() : Promise<boolean> {
return await TAURI_INVOKE("is_portable");
},
async getAppDirPath() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_app_dir_path") };

View file

@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ProgressBar } from "../shared";
import { useSettings } from "../../hooks/useSettings";
import { commands } from "../../bindings";
interface UpdateCheckerProps {
className?: string;
@ -18,6 +20,8 @@ const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
const [isInstalling, setIsInstalling] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [showUpToDate, setShowUpToDate] = useState(false);
const [showPortableUpdateDialog, setShowPortableUpdateDialog] =
useState(false);
const { settings, isLoading } = useSettings();
const settingsLoaded = !isLoading && settings !== null;
@ -97,6 +101,13 @@ const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
const installUpdate = async () => {
if (!updateChecksEnabled) return;
const portable = await commands.isPortable();
if (portable) {
setShowPortableUpdateDialog(true);
return;
}
try {
setIsInstalling(true);
setDownloadProgress(0);
@ -172,37 +183,68 @@ const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
!isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate));
return (
<div className={`flex items-center gap-3 ${className}`}>
{isUpdateClickable ? (
<button
onClick={getUpdateStatusAction()}
disabled={isUpdateDisabled}
className={`transition-colors disabled:opacity-50 tabular-nums ${
updateAvailable
? "text-logo-primary hover:text-logo-primary/80 font-medium"
: "text-text/60 hover:text-text/80"
}`}
>
{getUpdateStatusText()}
</button>
) : (
<span className="text-text/60 tabular-nums">
{getUpdateStatusText()}
</span>
<>
{showPortableUpdateDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-bg border border-border rounded-lg p-6 max-w-md w-full mx-4 space-y-4">
<h2 className="text-base font-semibold">
{t("footer.portableUpdateTitle")}
</h2>
<p className="text-sm text-text/70">
{t("footer.portableUpdateMessage")}
</p>
<div className="flex gap-2 justify-end">
<button
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-border/50 transition-colors"
onClick={() => setShowPortableUpdateDialog(false)}
>
{t("common.close")}
</button>
<button
className="px-3 py-1.5 text-sm rounded bg-logo-primary text-white hover:bg-logo-primary/80 transition-colors"
onClick={() => {
openUrl("https://github.com/cjpais/Handy/releases/latest");
setShowPortableUpdateDialog(false);
}}
>
{t("footer.portableUpdateButton")}
</button>
</div>
</div>
</div>
)}
<div className={`flex items-center gap-3 ${className}`}>
{isUpdateClickable ? (
<button
onClick={getUpdateStatusAction()}
disabled={isUpdateDisabled}
className={`transition-colors disabled:opacity-50 tabular-nums ${
updateAvailable
? "text-logo-primary hover:text-logo-primary/80 font-medium"
: "text-text/60 hover:text-text/80"
}`}
>
{getUpdateStatusText()}
</button>
) : (
<span className="text-text/60 tabular-nums">
{getUpdateStatusText()}
</span>
)}
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
<ProgressBar
progress={[
{
id: "update",
percentage: downloadProgress,
},
]}
size="large"
/>
)}
</div>
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
<ProgressBar
progress={[
{
id: "update",
percentage: downloadProgress,
},
]}
size="large"
/>
)}
</div>
</>
);
};

View file

@ -542,7 +542,10 @@
"downloading": "...جاري التنزيل {{progress}}%",
"installing": "...جاري التثبيت",
"preparing": "...جاري التحضير",
"checkForUpdates": "التحقق من وجود تحديثات"
"checkForUpdates": "التحقق من وجود تحديثات",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "...جاري التحميل",

View file

@ -542,7 +542,10 @@
"downloading": "Stahování... {{progress}}%",
"installing": "Instalace...",
"preparing": "Příprava...",
"checkForUpdates": "Zkontrolovat aktualizace"
"checkForUpdates": "Zkontrolovat aktualizace",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Načítání...",

View file

@ -542,7 +542,10 @@
"downloading": "Wird heruntergeladen... {{progress}}%",
"installing": "Wird installiert...",
"preparing": "Wird vorbereitet...",
"checkForUpdates": "Nach Updates suchen"
"checkForUpdates": "Nach Updates suchen",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Wird geladen...",

View file

@ -542,7 +542,10 @@
"downloading": "Downloading... {{progress}}%",
"installing": "Installing...",
"preparing": "Preparing...",
"checkForUpdates": "Check for updates"
"checkForUpdates": "Check for updates",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Loading...",

View file

@ -542,7 +542,10 @@
"downloading": "Descargando... {{progress}}%",
"installing": "Instalando...",
"preparing": "Preparando...",
"checkForUpdates": "Buscar actualizaciones"
"checkForUpdates": "Buscar actualizaciones",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Cargando...",

View file

@ -542,7 +542,10 @@
"downloading": "Téléchargement... {{progress}}%",
"installing": "Installation...",
"preparing": "Préparation...",
"checkForUpdates": "Rechercher des mises à jour"
"checkForUpdates": "Rechercher des mises à jour",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Chargement...",

View file

@ -542,7 +542,10 @@
"downloading": "Download... {{progress}}%",
"installing": "Installazione...",
"preparing": "Preparazione...",
"checkForUpdates": "Controlla aggiornamenti"
"checkForUpdates": "Controlla aggiornamenti",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Caricamento...",

View file

@ -542,7 +542,10 @@
"downloading": "ダウンロード中... {{progress}}%",
"installing": "インストール中...",
"preparing": "準備中...",
"checkForUpdates": "アップデートを確認"
"checkForUpdates": "アップデートを確認",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "読み込み中...",

View file

@ -542,7 +542,10 @@
"downloading": "다운로드 중... {{progress}}%",
"installing": "설치 중...",
"preparing": "준비 중...",
"checkForUpdates": "업데이트 확인"
"checkForUpdates": "업데이트 확인",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "로딩 중...",

View file

@ -542,7 +542,10 @@
"downloading": "Pobieranie... {{progress}}%",
"installing": "Instalowanie...",
"preparing": "Przygotowywanie...",
"checkForUpdates": "Sprawdź aktualizacje"
"checkForUpdates": "Sprawdź aktualizacje",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Wczytywanie...",

View file

@ -542,7 +542,10 @@
"downloading": "Baixando... {{progress}}%",
"installing": "Instalando...",
"preparing": "Preparando...",
"checkForUpdates": "Verificar atualizações"
"checkForUpdates": "Verificar atualizações",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Carregando...",

View file

@ -542,7 +542,10 @@
"downloading": "Загрузка... {{progress}} %",
"installing": "Установка...",
"preparing": "Подготовка...",
"checkForUpdates": "Проверить наличие обновлений"
"checkForUpdates": "Проверить наличие обновлений",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Загрузка...",

View file

@ -542,7 +542,10 @@
"downloading": "Laddar ner... {{progress}}%",
"installing": "Installerar...",
"preparing": "Förbereder...",
"checkForUpdates": "Sök efter uppdateringar"
"checkForUpdates": "Sök efter uppdateringar",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Laddar...",

View file

@ -542,7 +542,10 @@
"downloading": "İndiriliyor... %{{progress}}",
"installing": "Yükleniyor...",
"preparing": "Hazırlanıyor...",
"checkForUpdates": "Güncellemeleri kontrol et"
"checkForUpdates": "Güncellemeleri kontrol et",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Yükleniyor...",

View file

@ -542,7 +542,10 @@
"downloading": "Завантаження... {{progress}}%",
"installing": "Встановлення...",
"preparing": "Підготовка...",
"checkForUpdates": "Перевірити оновлення"
"checkForUpdates": "Перевірити оновлення",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Завантаження...",

View file

@ -542,7 +542,10 @@
"downloading": "Đang tải... {{progress}}%",
"installing": "Đang cài đặt...",
"preparing": "Đang chuẩn bị...",
"checkForUpdates": "Kiểm tra cập nhật"
"checkForUpdates": "Kiểm tra cập nhật",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "Đang tải...",

View file

@ -542,7 +542,10 @@
"downloading": "下載中... {{progress}}%",
"installing": "安裝中...",
"preparing": "準備中...",
"checkForUpdates": "檢查更新"
"checkForUpdates": "檢查更新",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "載入中...",

View file

@ -542,7 +542,10 @@
"downloading": "下载中... {{progress}}%",
"installing": "安装中...",
"preparing": "准备中...",
"checkForUpdates": "检查更新"
"checkForUpdates": "检查更新",
"portableUpdateTitle": "Manual update required",
"portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.",
"portableUpdateButton": "Open GitHub Releases"
},
"common": {
"loading": "加载中...",