From ea4a5988f032cffe1485027da0516808854541b5 Mon Sep 17 00:00:00 2001 From: Nick Foy Date: Sun, 8 Nov 2026 06:01:19 -0800 Subject: [PATCH] Add portable FreeCAD preferences and Omarchy font launcher --- .github/workflows/freecad.yml | 20 ++++ README.md | 1 + freecad/README.md | 85 +++++++++++++++++ freecad/export.py | 75 +++++++++++++++ freecad/install.py | 115 ++++++++++++++++++++++ freecad/launcher.py | 18 ++++ freecad/macros/README.md | 5 + freecad/pack/NickOmarchy/NickOmarchy.cfg | 116 +++++++++++++++++++++++ freecad/pack/package.xml | 18 ++++ freecad/tests/check_freecad.py | 15 +++ freecad/tests/test_install.py | 61 ++++++++++++ freecad/tests/test_launcher.py | 32 +++++++ freecad/tests/test_profile.py | 32 +++++++ 13 files changed, 593 insertions(+) create mode 100644 .github/workflows/freecad.yml create mode 100644 freecad/README.md create mode 100644 freecad/export.py create mode 100644 freecad/install.py create mode 100755 freecad/launcher.py create mode 100644 freecad/macros/README.md create mode 100644 freecad/pack/NickOmarchy/NickOmarchy.cfg create mode 100644 freecad/pack/package.xml create mode 100644 freecad/tests/check_freecad.py create mode 100644 freecad/tests/test_install.py create mode 100644 freecad/tests/test_launcher.py create mode 100644 freecad/tests/test_profile.py diff --git a/.github/workflows/freecad.yml b/.github/workflows/freecad.yml new file mode 100644 index 0000000..1dee79d --- /dev/null +++ b/.github/workflows/freecad.yml @@ -0,0 +1,20 @@ +name: FreeCAD setup +on: + push: + pull_request: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate package + run: | + python3 -m py_compile freecad/install.py freecad/export.py freecad/launcher.py + python3 freecad/tests/test_profile.py + python3 freecad/tests/test_install.py + python3 freecad/tests/test_launcher.py diff --git a/README.md b/README.md index a799467..25c2f9b 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,4 @@ Portable fixes and configuration for Nick's Omarchy desktops. - [Fusion 360 window compatibility](fusion360/): preserve Fusion palettes, transparency and dialog input under Wine/XWayland on Hyprland. +- [FreeCAD setup](freecad/): portable Preference Pack, Omarchy font scaling, and a repeatable install/export/restore workflow. diff --git a/freecad/README.md b/freecad/README.md new file mode 100644 index 0000000..819db3b --- /dev/null +++ b/freecad/README.md @@ -0,0 +1,85 @@ +# Nick's FreeCAD setup + +A portable **NickOmarchy Preference Pack**, the Omarchy text-scaling launcher, and a home for future modeling macros. Baseline captured from saved FreeCAD 1.1.4 settings on November 8, 2026. + +## New laptop + +Install native FreeCAD on Omarchy/Arch first, then register this setup: + +```bash +omarchy pkg add freecad +git clone https://github.com/nickfoy/sharedomarchy.git +cd sharedomarchy +python3 freecad/install.py install +``` + +Restart FreeCAD from its app-launcher entry when convenient. In **Edit → Preferences → General**, find **NickOmarchy** and apply it explicitly to restore the baseline. Installing this package registers it but does **not** overwrite your live preferences or close your documents. FreeCAD may request a restart after applying a pack. + +The installer queries `FreeCADCmd --get-config UserAppData` to find the version-specific data directory. On the tested system it is `~/.local/share/FreeCAD/v1-1/`. Runtime prerequisites are native `/usr/bin/FreeCAD`, `FreeCADCmd`, Python 3.10+, `gsettings`, and the Arch desktop entry `/usr/share/applications/org.freecad.FreeCAD.desktop`. + +## Baseline contents + +- Part Design startup workbench; Standard millimeter/kilogram/second units. +- CAD navigation style. +- FreeCAD Dark theme, built-in stylesheet names and view/background/selection colors. +- Saved editor/console appearance, tree font/padding and navigation-cube appearance where present. +- Toolbar visibility. Screen coordinates, window dimensions and serialized Qt layouts are excluded. + +The exporter uses a defined set of preference groups/keys. It excludes recent files/macros, document paths, plugin paths, language, account data, unknown text preferences and absolute stylesheet paths. A future preference outside this set must be deliberately added to `export.py` and its tests. It does not copy all of `user.cfg`. + +**Font scaling is independent of the pack.** The launcher reads `org.gnome.desktop.interface text-scaling-factor` at each start and supplies `QT_FONT_DPI=round(96×scale)`. On the tested desktop the factor was `0.8182`, producing `79` DPI instead of Qt's default `96`. An explicit existing `QT_FONT_DPI` is respected. If settings are unavailable or invalid it falls back to Qt's default. The default GTK font family still comes from the desktop. Font changes require restarting FreeCAD; this is not a live settings watcher. Launching `/usr/bin/FreeCAD` directly bypasses the wrapper. + +## Save future adjustments + +1. Make your FreeCAD changes, save your documents, then close FreeCAD so its current preferences are written. +2. Export selected saved preferences into the version-controlled pack: + + ```bash + cd ~/Work/sharedomarchy # or wherever you cloned it + python3 freecad/export.py --source ~/.config/FreeCAD/v1-1/user.cfg + git diff -- freecad/pack/NickOmarchy/NickOmarchy.cfg + ``` + + Adjust the `v1-1` path for a future FreeCAD version. Inspect the diff before publishing, especially if you extend the export rules. +3. Run the scoped tests below, then commit/push the pack changes. +4. Run `python3 freecad/install.py install` to update the registered pack. Applying the pack is optional on the laptop where you just made the settings; those preferences are already active there. + +You can also use FreeCAD's **Save new…** Preference Pack UI to capture selected preferences. Review any resulting CFG before adding it to this public repository. Keep full `user.cfg` backups local/private because they can contain recent file paths and other personal state. + +## Files and removal + +The installer backs up and manages these files only: + +- `~/.local/bin/freecad-omarchy` +- `~/.local/share/applications/org.freecad.FreeCAD.desktop` +- `/Preference Packs/NickOmarchy/package.xml` +- `/Preference Packs/NickOmarchy/LICENSE` +- `/Preference Packs/NickOmarchy/NickOmarchy/NickOmarchy.cfg` + +State and retained backups are under `~/.local/share/sharedomarchy-freecad/`. Reinstalling preserves the original backup; local modifications to managed files cause a refusal rather than being discarded. Missing backups prevent removal before any managed files are changed. Symlinked managed files are rejected; integrate them through your dotfile manager instead. + +```bash +python3 freecad/install.py uninstall +``` + +Uninstall restores pre-existing files or removes files the package created. It leaves your actual FreeCAD preferences and documents unchanged. If you applied NickOmarchy and want to undo those preferences, restore the timestamped backup FreeCAD made when applying the pack (under `SavedPreferencePacks/Backups` in UserAppData). FreeCAD normally retains these for one week; keep a separate local backup for longer retention. Uninstalling the package does not undo applied preferences. + +For offline staging/tests, `--home /target/home --data-dir /target/freecad-data --desktop-template /path/to/upstream.desktop` avoids querying a running installation. After a FreeCAD upgrade changes the data directory, uninstall the old registration before installing to the new version. + +## Macros + +`macros/` is reserved for reusable modeling automation. No startup/pre/post macro is installed: restoring preferences should not run modeling code. + +## Validation + +From the repo root: + +```bash +python3 freecad/tests/test_profile.py +python3 freecad/tests/test_install.py +python3 freecad/tests/test_launcher.py +``` + +The pack was also loaded by FreeCADCmd **1.1.4** using an isolated temporary user configuration, with the live settings hash verified unchanged. The app and font launcher were commissioned on Hyprland **0.56.2**, native Wayland, scale **1.25**. That commissioning included editable parameter recompute, save/reopen, STEP round-trip, watertight STL export and GUI resize/redraw. Test models/screenshots and full workstation settings are not included here. + +On a new laptop, verify launcher startup, text sizing, pack appearance in Preferences, apply/restart, toolbar/navigation settings, and model save/reopen. Unit tests do not simulate Qt, the Preference Pack selection UI or a compositor. diff --git a/freecad/export.py b/freecad/export.py new file mode 100644 index 0000000..f8b2024 --- /dev/null +++ b/freecad/export.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Export selected saved FreeCAD preferences; never copy an entire user.cfg.""" +import argparse +import copy +from pathlib import Path +import xml.etree.ElementTree as ET + +# Unlisted groups and strings stay private. Numeric appearance values have no +# file paths, but geometry/column widths are intentionally not in these groups. +NUMERIC_GROUPS = {'View', 'Editor', 'Themes', 'OutputWindow', 'NaviCube'} +KEYS = { + 'General': {'AutoloadModule', 'FontSize'}, + 'Units': {'UserSchema', 'Decimals'}, + 'View': {'NavigationStyle'}, + 'TreeView': {'FontSize', 'ItemBackgroundPadding', 'TreeActiveColor', 'TreeEditColor'}, + 'MainWindow': {'Theme', 'QtStyle', 'StyleSheet', 'OverlayActiveStyleSheet', + 'InputHintEnabled', 'QuickMeasureEnabled', 'StatusBar'}, + 'Editor': {'Font'}, +} + + +def portable_profile(source): + result = ET.Element('FCParameters') + root = ET.SubElement(result, 'FCParamGroup', Name='Root') + base = ET.SubElement(root, 'FCParamGroup', Name='BaseApp') + prefs = ET.SubElement(base, 'FCParamGroup', Name='Preferences') + src_base = source.find("./FCParamGroup[@Name='Root']/FCParamGroup[@Name='BaseApp']") + if src_base is None: + raise ValueError('Not a FreeCAD user.cfg: missing Root/BaseApp') + src_prefs = src_base.find("FCParamGroup[@Name='Preferences']") + if src_prefs is None: + raise ValueError('Missing Preferences group') + count = 0 + for group in src_prefs: + name = group.get('Name') + selected = [] + for entry in group: + key = entry.get('Name', '') + allowed = key in KEYS.get(name, set()) + allowed |= name in NUMERIC_GROUPS and entry.tag in {'FCInt', 'FCUInt', 'FCFloat', 'FCBool'} + if not allowed or entry.tag == 'FCParamGroup': + continue + if entry.tag == 'FCText': + text = entry.text or '' + if any(token in text for token in ('/', '\\', ':')) and key != 'NavigationStyle': + continue + selected.append(copy.deepcopy(entry)) + if selected: + dest = ET.SubElement(prefs, 'FCParamGroup', Name=name) + dest.extend(selected) + count += len(selected) + # Visibility toggles are portable; serialized Qt layout and window positions are not. + toolbars = src_base.find("FCParamGroup[@Name='MainWindow']/FCParamGroup[@Name='Toolbars']") + if toolbars is not None: + dest = ET.SubElement(ET.SubElement(base, 'FCParamGroup', Name='MainWindow'), 'FCParamGroup', Name='Toolbars') + dest.extend(copy.deepcopy(e) for e in toolbars if e.tag == 'FCBool') + if not count: + raise ValueError('No supported preferences found') + ET.indent(result, space=' ') + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, required=True, help='Saved user.cfg; close FreeCAD first to flush changes') + parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parent / 'pack/NickOmarchy/NickOmarchy.cfg') + args = parser.parse_args() + result = portable_profile(ET.parse(args.source).getroot()) + args.output.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(result).write(args.output, encoding='UTF-8', xml_declaration=True) + print(f'Exported portable baseline to {args.output}') + + +if __name__ == '__main__': + main() diff --git a/freecad/install.py b/freecad/install.py new file mode 100644 index 0000000..ad55b0c --- /dev/null +++ b/freecad/install.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Register Nick's FreeCAD Preference Pack and install the Omarchy launcher.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def desktop_quote(path): + text = str(path).replace('%', '%%') + for char in ('\\', '"', '`', '$'): + text = text.replace(char, '\\' + char) + return '"' + text + '"' + + +def manage(args): + home = args.home.resolve() + state_file = home / '.local/share/sharedomarchy-freecad/state.json' + state = json.loads(state_file.read_text()) if state_file.exists() else None + if args.action == 'uninstall': + if not state: + print('No managed FreeCAD setup to remove.') + return + for target, record in state.items(): + path = Path(target) + if path.is_symlink() or not path.is_file() or sha(path.read_bytes()) != record['sha']: + raise RuntimeError(f'Preserving edited/missing file: {target}. Reconcile it before removal.') + # Validate all backup reads before making any changes. + originals = {target: Path(r['backup']).read_bytes() if r['backup'] else None + for target, r in state.items()} + for target, original in originals.items(): + path = Path(target) + if original is None: + path.unlink() + else: + path.write_bytes(original) + shutil.copystat(state[target]['backup'], path) + state_file.unlink() + print('Removed managed files and restored originals; preferences and backups retained.') + return + + if args.home.resolve() != Path.home().resolve() and args.data_dir is None: + raise RuntimeError('--home for another user requires --data-dir for offline staging.') + data_dir = args.data_dir + if data_dir is None: + value = subprocess.check_output(['FreeCADCmd', '--get-config', 'UserAppData'], text=True).strip() + data_dir = Path(value) + if not data_dir.is_absolute(): + raise RuntimeError('Could not determine the FreeCAD user data directory.') + pack_dir = data_dir.resolve() / 'Preference Packs/NickOmarchy' + source = Path(__file__).resolve().parent + template = args.desktop_template.read_text() + wrapper = home / '.local/bin/freecad-omarchy' + desktop, count = re.subn(r'^Exec=(?:FreeCAD|freecad|/usr/bin/FreeCAD)([^\n]*)$', + lambda m: 'Exec=' + desktop_quote(wrapper) + m[1], template, flags=re.M) + if count != 1: + raise RuntimeError('Unexpected upstream desktop Exec; no files changed.') + files = { + wrapper: (source / 'launcher.py').read_bytes(), + home / '.local/share/applications/org.freecad.FreeCAD.desktop': desktop.encode(), + pack_dir / 'package.xml': (source / 'pack/package.xml').read_bytes(), + pack_dir / 'LICENSE': (source.parent / 'LICENSE').read_bytes(), + pack_dir / 'NickOmarchy/NickOmarchy.cfg': (source / 'pack/NickOmarchy/NickOmarchy.cfg').read_bytes(), + } + if state and set(state) != {str(p) for p in files}: + raise RuntimeError('FreeCAD data location changed; uninstall the old registration before reinstalling.') + for target in files: + if target.is_symlink() or (target.exists() and not target.is_file()): + raise RuntimeError(f'Refusing to replace a symlink or directory: {target}') + if state and (not target.exists() or sha(target.read_bytes()) != state[str(target)]['sha']): + raise RuntimeError(f'Preserving edited/missing file: {target}. Reconcile it before updating.') + if not state: + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + backup_dir = home / '.local/share/sharedomarchy-freecad/backups' / stamp + backup_dir.mkdir(parents=True) + state = {} + for index, target in enumerate(files): + backup = backup_dir / str(index) if target.exists() else None + if backup: + shutil.copy2(target, backup) + state[str(target)] = {'backup': str(backup) if backup else None} + for target, payload in files.items(): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload) + target.chmod(0o755 if target == wrapper else 0o644) + state[str(target)]['sha'] = sha(payload) + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps(state, indent=2) + '\n') + print('Installed launcher and registered NickOmarchy. Apply it from FreeCAD Preferences when desired.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('install', 'uninstall')) + parser.add_argument('--home', type=Path, default=Path.home()) + parser.add_argument('--data-dir', type=Path, help='FreeCAD UserAppData directory; auto-detected by default') + parser.add_argument('--desktop-template', type=Path, + default=Path('/usr/share/applications/org.freecad.FreeCAD.desktop')) + manage(parser.parse_args()) + + +if __name__ == '__main__': + try: + main() + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + sys.exit(str(exc)) diff --git a/freecad/launcher.py b/freecad/launcher.py new file mode 100755 index 0000000..4415924 --- /dev/null +++ b/freecad/launcher.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Start FreeCAD with Omarchy's current GNOME/GTK text scale applied to Qt.""" +import math +import os +import subprocess +import sys + +if 'QT_FONT_DPI' not in os.environ: + try: + scale = float(subprocess.check_output( + ['gsettings', 'get', 'org.gnome.desktop.interface', 'text-scaling-factor'], + text=True, stderr=subprocess.DEVNULL, timeout=3).strip()) + if math.isfinite(scale) and 0.25 <= scale <= 4: + os.environ['QT_FONT_DPI'] = str(round(96 * scale)) + except (OSError, ValueError, subprocess.SubprocessError): + pass # Fall back to Qt's normal font settings if GNOME settings are absent. + +os.execv('/usr/bin/FreeCAD', ['/usr/bin/FreeCAD', *sys.argv[1:]]) diff --git a/freecad/macros/README.md b/freecad/macros/README.md new file mode 100644 index 0000000..3a224b4 --- /dev/null +++ b/freecad/macros/README.md @@ -0,0 +1,5 @@ +# Modeling macros + +Put reusable, reviewed `.FCMacro` modeling automation here as it is developed. +Appearance and navigation preferences belong in the Preference Pack, not a startup macro. +No automatic modeling or pre/post-application macro is installed by this package. diff --git a/freecad/pack/NickOmarchy/NickOmarchy.cfg b/freecad/pack/NickOmarchy/NickOmarchy.cfg new file mode 100644 index 0000000..3da6134 --- /dev/null +++ b/freecad/pack/NickOmarchy/NickOmarchy.cfg @@ -0,0 +1,116 @@ + + + + + + + PartDesignWorkbench + + + + + + Gui::CADNavigationStyle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Freecad Overlay.qss + FreeCAD + FreeCAD.qss + FreeCAD Dark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/freecad/pack/package.xml b/freecad/pack/package.xml new file mode 100644 index 0000000..2649366 --- /dev/null +++ b/freecad/pack/package.xml @@ -0,0 +1,18 @@ + + + Nick Omarchy FreeCAD Setup + Portable UI preferences, units, navigation, colors and toolbar visibility. + 1.0.0 + nickfoy + MIT + https://github.com/nickfoy/sharedomarchy + + + NickOmarchy + Nick's saved Omarchy baseline; apply explicitly to restore selected preferences. + 1.0.0 + personal + omarchy + + + diff --git a/freecad/tests/check_freecad.py b/freecad/tests/check_freecad.py new file mode 100644 index 0000000..9b701b3 --- /dev/null +++ b/freecad/tests/check_freecad.py @@ -0,0 +1,15 @@ +"""Run with FreeCADCmd -u freecad/tests/check_freecad.py. + +Copy the pack CFG to the temporary user.cfg first; this only reads it. +""" +from pathlib import Path +import FreeCAD as App + +profile = Path(__file__).resolve().parents[1] / 'pack/NickOmarchy/NickOmarchy.cfg' +prefs = App.ParamGet('User parameter:BaseApp/Preferences') +assert prefs.GetGroup('General').GetString('AutoloadModule') == 'PartDesignWorkbench' +assert prefs.GetGroup('Units').GetInt('UserSchema', -1) == 0 +assert prefs.GetGroup('View').GetString('NavigationStyle') == 'Gui::CADNavigationStyle' +assert prefs.GetGroup('MainWindow').GetString('Theme') == 'FreeCAD Dark' +assert prefs.GetGroup('General').GetString('FileOpenSavePath') == '' +print('PROFILE_LOAD_PASSED', '.'.join(App.Version()[:3])) diff --git a/freecad/tests/test_install.py b/freecad/tests/test_install.py new file mode 100644 index 0000000..4552078 --- /dev/null +++ b/freecad/tests/test_install.py @@ -0,0 +1,61 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +INSTALLER = Path(__file__).resolve().parents[1] / 'install.py' + +class InstallTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="freecad laptop's home ") + self.addCleanup(self.tmp.cleanup) + self.home = Path(self.tmp.name) + self.data = self.home / '.local/share/FreeCAD/v1-1' + self.template = self.home / 'upstream.desktop' + self.template.write_text('[Desktop Entry]\nName=FreeCAD\nExec=FreeCAD - --single-instance %F\n') + self.wrapper = self.home / '.local/bin/freecad-omarchy' + self.wrapper.parent.mkdir(parents=True) + self.wrapper.write_text('# preexisting launcher\n') + self.config = self.home / '.config/FreeCAD/v1-1/user.cfg' + self.config.parent.mkdir(parents=True) + self.config.write_text('private settings must stay unchanged') + + def run_action(self, action, success=True): + result = subprocess.run([sys.executable, str(INSTALLER), action, '--home',str(self.home), + '--data-dir',str(self.data),'--desktop-template',str(self.template)],capture_output=True,text=True) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + + def test_roundtrip_preserves_live_preferences_and_previous_launcher(self): + self.run_action('install') + first = self.wrapper.read_bytes() + self.run_action('install') + self.assertEqual(first,self.wrapper.read_bytes()) + pack=self.data/'Preference Packs/NickOmarchy/NickOmarchy/NickOmarchy.cfg' + self.assertTrue(pack.exists()) + desktop=self.home/'.local/share/applications/org.freecad.FreeCAD.desktop' + self.assertIn('"'+str(self.wrapper)+'" - --single-instance %F',desktop.read_text()) + self.assertEqual(self.config.read_text(),'private settings must stay unchanged') + self.run_action('uninstall') + self.assertEqual(self.wrapper.read_text(),'# preexisting launcher\n') + self.assertFalse(pack.exists()) + self.assertFalse(desktop.exists()) + self.run_action('uninstall') + + def test_edited_payload_is_preserved(self): + self.run_action('install') + self.wrapper.write_text('# later edits\n') + self.run_action('uninstall',success=False) + self.assertEqual(self.wrapper.read_text(),'# later edits\n') + + def test_missing_backup_prevents_partial_uninstall(self): + self.run_action('install') + state=json.loads((self.home/'.local/share/sharedomarchy-freecad/state.json').read_text()) + backup=next(v['backup'] for v in state.values() if v['backup']) + Path(backup).unlink() + previous=self.wrapper.read_bytes() + self.run_action('uninstall',success=False) + self.assertEqual(self.wrapper.read_bytes(),previous) + +if __name__ == '__main__': unittest.main() diff --git a/freecad/tests/test_launcher.py b/freecad/tests/test_launcher.py new file mode 100644 index 0000000..1371b21 --- /dev/null +++ b/freecad/tests/test_launcher.py @@ -0,0 +1,32 @@ +import os +from pathlib import Path +import runpy +import unittest +from unittest.mock import patch + +SCRIPT = Path(__file__).resolve().parents[1] / 'launcher.py' + +class LauncherTests(unittest.TestCase): + def run_launcher(self, scale, existing=None): + env = {} if existing is None else {'QT_FONT_DPI':existing} + captured = {} + def execute(binary, args): + captured.update(binary=binary,args=args,dpi=os.environ.get('QT_FONT_DPI')) + with patch.dict(os.environ,env,clear=True), patch('subprocess.check_output',return_value=scale), patch('os.execv',side_effect=execute), patch('sys.argv',[str(SCRIPT),'--single-instance','a part.FCStd']): + runpy.run_path(str(SCRIPT),run_name='__main__') + return captured + + def test_omarchy_scale_and_filename_are_forwarded(self): + result=self.run_launcher('0.8182\n') + self.assertEqual(result['dpi'],'79') + self.assertEqual(result['args'],['/usr/bin/FreeCAD','--single-instance','a part.FCStd']) + + def test_explicit_qt_override_is_preserved(self): + self.assertEqual(self.run_launcher('0.8182','88')['dpi'],'88') + + def test_invalid_scale_uses_qt_default(self): + for invalid in ['nan','garbage','0','999']: + with self.subTest(invalid=invalid): + self.assertIsNone(self.run_launcher(invalid)['dpi']) + +if __name__ == '__main__': unittest.main() diff --git a/freecad/tests/test_profile.py b/freecad/tests/test_profile.py new file mode 100644 index 0000000..d33be27 --- /dev/null +++ b/freecad/tests/test_profile.py @@ -0,0 +1,32 @@ +import importlib.util +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + +MODULE = Path(__file__).resolve().parents[1] / 'export.py' + +class ProfileTests(unittest.TestCase): + def test_export_keeps_portable_preferences_and_excludes_private_state(self): + self.assertTrue(MODULE.exists(), 'portable exporter is missing') + spec = importlib.util.spec_from_file_location('profile_export', MODULE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + source = ET.fromstring(''' + + PartDesignWorkbench/home/private/designs + + Gui::CADNavigationStyle + FreeCAD Dark-3072 0 1470 818/home/private/theme.qss + /home/private/secret.FCStd + + + + ''') + result = ET.tostring(module.portable_profile(source), encoding='unicode') + for value in ['AutoloadModule','UserSchema','NavigationStyle','BackgroundColor','Theme','FontSize','Toolbars']: + self.assertIn(value, result) + for value in ['/home/', 'FileOpenSavePath','Geometry','StyleSheet','RecentFiles','ColumnSize1']: + self.assertNotIn(value, result) + +if __name__ == '__main__': + unittest.main()